Merge branch 'master' into add-saml-login

This commit is contained in:
J Shamsul Bahri (jibone))
2020-09-04 18:28:09 +08:00
823 changed files with 32014 additions and 5907 deletions
+2 -2
View File
@@ -2,9 +2,9 @@
Backstage is a single-page application composed of a set of plugins.
Our goal for the plugin ecosystem is that the definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following strong [design guidelines](https://github.com/spotify/backstage/blob/master/docs/design.md) we ensure the the overall user experience stays consistent between plugins.
Our goal for the plugin ecosystem is that the definition of a plugin is flexible enough to allow you to expose pretty much any kind of infrastructure or software development tool as a plugin in Backstage. By following strong [design guidelines](https://github.com/spotify/backstage/blob/master/docs/dls/design.md) we ensure the the overall user experience stays consistent between plugins.
![plugin](../docs/plugins/my-plugin_screenshot.png)
![plugin](../docs/assets/my-plugin_screenshot.png)
## Creating a plugin
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+27
View File
@@ -0,0 +1,27 @@
# API Documentation
WORK IN PROGRESS
This is an extension for the catalog plugin that provides components to discover and display API entities.
APIs define the interface between components, see the [system model](https://backstage.io/docs/features/software-catalog/system-model) for details.
They are defined in machine readable formats and provide a human readable documentation.
The plugin provides a standalone list of APIs, as well as an integration into the API tab of a catalog entity.
![Standalone API list](./docs/api_list.png)
![OpenAPI Definition](./docs/openapi_definition.png)
![Integration into components](./docs/entity_tab_api.png)
Right now, the following API formats are supported:
- [OpenAPI](https://swagger.io/specification/) 2 & 3,
- [AsyncAPI](https://www.asyncapi.com/docs/specifications/latest/),
Other formats are displayed as plain text, but this can easily be extented.
To fill the catalog with APIs, [provide entities of kind API](https://backstage.io/docs/features/software-catalog/descriptor-format#kind-api).
To link that an component implements an API, see [`implementsApis` property on components](https://backstage.io/docs/features/software-catalog/descriptor-format#specimplementsapis-optional).
## Links
- [The Backstage homepage](https://backstage.io)
+20
View File
@@ -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.
*/
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+54
View File
@@ -0,0 +1,54 @@
{
"name": "@backstage/plugin-api-docs",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/plugin-catalog": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@kyma-project/asyncapi-react": "^0.11.0",
"@material-icons/font": "^1.0.2",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"swagger-ui-react": "^3.31.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@backstage/test-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/react": "^16.9",
"@types/swagger-ui-react": "^3.23.3",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist"
]
}
@@ -0,0 +1,37 @@
/*
* 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 { Header, Page, pageTheme } from '@backstage/core';
import React from 'react';
type Props = {
children?: React.ReactNode;
};
const ApiCatalogLayout = ({ children }: Props) => {
return (
<Page theme={pageTheme.home}>
<Header
title="APIs"
subtitle="Backstage API Catalog"
pageTitleOverride="Home"
/>
{children}
</Page>
);
};
export default ApiCatalogLayout;
@@ -0,0 +1,70 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core';
// TODO: Circular ref!
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { ApiCatalogPage } from './ApiCatalogPage';
describe('ApiCatalogPage', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity1',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity2',
},
},
] as Entity[]),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[storageApiRef, MockStorageApi.create()],
])}
>
{children}
</ApiProvider>,
),
);
// this test right now causes some red lines in the log output when running tests
// related to some theme issues in mui-table
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
const { findByText } = renderWrapped(<ApiCatalogPage />);
expect(await findByText(/APIs \(2\)/)).toBeInTheDocument();
});
});
@@ -0,0 +1,45 @@
/*
* 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 { Content, useApi } from '@backstage/core';
// TODO: Circular ref
import { catalogApiRef } from '@backstage/plugin-catalog';
import React from 'react';
import { useAsync } from 'react-use';
import { ApiCatalogTable } from '../ApiCatalogTable/ApiCatalogTable';
import ApiCatalogLayout from './ApiCatalogLayout';
const CatalogPageContents = () => {
const catalogApi = useApi(catalogApiRef);
const { loading, error, value: matchingEntities } = useAsync(() => {
return catalogApi.getEntities({ kind: 'API' });
}, [catalogApi]);
return (
<ApiCatalogLayout>
<Content>
<ApiCatalogTable
titlePreamble="APIs"
entities={matchingEntities!}
loading={loading}
error={error}
/>
</Content>
</ApiCatalogLayout>
);
};
export const ApiCatalogPage = () => <CatalogPageContents />;
@@ -0,0 +1,74 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import * as React from 'react';
import { ApiCatalogTable } from './ApiCatalogTable';
const entites: Entity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api1' },
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api2' },
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api3' },
},
];
describe('ApiCatalogTable component', () => {
it('should render error message when error is passed in props', async () => {
const rendered = render(
wrapInTestApp(
<ApiCatalogTable
titlePreamble="APIs"
entities={[]}
loading={false}
error={{ code: 'error' }}
/>,
),
);
const errorMessage = await rendered.findByText(
/Error encountered while fetching catalog entities./,
);
expect(errorMessage).toBeInTheDocument();
});
it('should display entity names when loading has finished and no error occurred', async () => {
const rendered = render(
wrapInTestApp(
<ApiCatalogTable
titlePreamble="APIs"
entities={entites}
loading={false}
/>,
),
);
expect(rendered.getByText(/APIs \(3\)/)).toBeInTheDocument();
expect(rendered.getByText(/api1/)).toBeInTheDocument();
expect(rendered.getByText(/api2/)).toBeInTheDocument();
expect(rendered.getByText(/api3/)).toBeInTheDocument();
});
});
@@ -0,0 +1,91 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { entityRoute } from '../../routes';
const columns: TableColumn<Entity>[] = [
{
title: 'Name',
field: 'metadata.name',
highlight: true,
render: (entity: any) => (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, {
optionalNamespaceAndName: [
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
kind: entity.kind,
selectedTabId: 'overview',
})}
>
{entity.metadata.name}
</Link>
),
},
{
title: 'Description',
field: 'metadata.description',
},
];
type CatalogTableProps = {
entities: Entity[];
titlePreamble: string;
loading: boolean;
error?: any;
};
export const ApiCatalogTable = ({
entities,
loading,
error,
titlePreamble,
}: CatalogTableProps) => {
if (error) {
return (
<div>
<Alert severity="error">
Error encountered while fetching catalog entities. {error.toString()}
</Alert>
</div>
);
}
return (
<Table<Entity>
isLoading={loading}
columns={columns}
options={{
paging: false,
actionsColumnIndex: -1,
loadingType: 'linear',
showEmptyDataSourceMessage: !loading,
}}
title={`${titlePreamble} (${(entities && entities.length) || 0})`}
data={entities}
/>
);
};
@@ -0,0 +1,36 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiEntityV1alpha1 } from '@backstage/catalog-model';
import { InfoCard } from '@backstage/core';
import React from 'react';
import { ApiDefinitionWidget } from '../ApiDefinitionWidget/ApiDefinitionWidget';
type Props = {
title?: string;
apiEntity: ApiEntityV1alpha1;
};
export const ApiDefinitionCard = ({ title, apiEntity }: Props) => {
const type = apiEntity?.spec?.type || '';
const definition = apiEntity?.spec?.definition || '';
return (
<InfoCard title={title} subheader={type}>
<ApiDefinitionWidget type={type} definition={definition} />
</InfoCard>
);
};
@@ -0,0 +1,40 @@
/*
* 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 React from 'react';
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget/AsyncApiDefinitionWidget';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget/OpenApiDefinitionWidget';
import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget/PlainApiDefinitionWidget';
type Props = {
type: string;
definition: string;
};
export const ApiDefinitionWidget = ({ type, definition }: Props) => {
switch (type) {
case 'openapi':
return <OpenApiDefinitionWidget definition={definition} />;
case 'asyncapi':
return <AsyncApiDefinitionWidget definition={definition} />;
default:
return (
<PlainApiDefinitionWidget definition={definition} language={type} />
);
}
};
@@ -14,6 +14,14 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { ApiEntityPage, getPageTheme } from './ApiEntityPage';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mockNavigate = jest.fn();
@@ -24,14 +32,6 @@ jest.mock('react-router-dom', () => {
};
});
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityPage, getPageTheme } from './EntityPage';
const {
useParams,
useNavigate,
@@ -41,7 +41,7 @@ const {
const errorApi = { post: () => {} };
describe('EntityPage', () => {
describe('ApiEntityPage', () => {
it('should redirect to catalog page when name is not provided', async () => {
useParams.mockReturnValue({
kind: 'Component',
@@ -61,12 +61,14 @@ describe('EntityPage', () => {
],
])}
>
<EntityPage />
<ApiEntityPage />
</ApiProvider>,
),
);
await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
await waitFor(() =>
expect(useNavigate()).toHaveBeenCalledWith('/api-docs'),
);
});
});
@@ -0,0 +1,134 @@
/*
* 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 { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model';
import {
Content,
errorApiRef,
Header,
Page,
pageTheme,
PageTheme,
Progress,
useApi,
} from '@backstage/core';
// TODO: Circular ref
import { catalogApiRef } from '@backstage/plugin-catalog';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { ApiDefinitionCard } from '../ApiDefinitionCard/ApiDefinitionCard';
const REDIRECT_DELAY = 1000;
function headerProps(
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
return {
headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`,
headerType: (() => {
let t = kind.toLowerCase();
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLowerCase();
}
return t;
})(),
};
}
export const getPageTheme = (entity?: Entity): PageTheme => {
const themeKey = entity?.spec?.type?.toString() ?? 'home';
return pageTheme[themeKey] ?? pageTheme.home;
};
type EntityPageTitleProps = {
title: string;
entity: Entity | undefined;
};
const EntityPageTitle = ({ title }: EntityPageTitleProps) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
</Box>
);
export const ApiEntityPage = () => {
const { optionalNamespaceAndName } = useParams() as {
optionalNamespaceAndName: string;
};
const navigate = useNavigate();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const { value: entity, error, loading } = useAsync(
() => catalogApi.getEntityByName({ kind: 'API', namespace, name }),
[catalogApi, namespace, name],
);
useEffect(() => {
if (!error && !loading && !entity) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
navigate('/');
}, REDIRECT_DELAY);
}
}, [errorApi, navigate, error, loading, entity]);
if (!name) {
navigate('/api-docs');
return null;
}
const { headerTitle, headerType } = headerProps(
'API',
namespace,
name,
entity,
);
return (
<Page theme={getPageTheme(entity)}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity} />}
pageTitleOverride={headerTitle}
type={headerType}
/>
{loading && <Progress />}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
{entity && (
<>
<Content>
<ApiDefinitionCard apiEntity={entity as ApiEntityV1alpha1} />
</Content>
</>
)}
</Page>
);
};
@@ -0,0 +1,150 @@
/*
* 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 AsyncApi from '@kyma-project/asyncapi-react';
import React from 'react';
import { makeStyles, fade } from '@material-ui/core/styles';
import '@kyma-project/asyncapi-react/lib/styles/fiori.css';
const useStyles = makeStyles(theme => ({
root: {
'& .asyncapi': {
'font-family': 'inherit',
background: 'none',
},
'& h2': {
...theme.typography.h6,
},
'& .text-teal': {
color: theme.palette.primary.main,
},
'& button': {
...theme.typography.button,
background: 'none',
boxSizing: 'border-box',
minWidth: 64,
borderRadius: theme.shape.borderRadius,
transition: theme.transitions.create(
['background-color', 'box-shadow', 'border'],
{
duration: theme.transitions.duration.short,
},
),
padding: '5px 15px',
color: theme.palette.primary.main,
border: `1px solid ${fade(theme.palette.primary.main, 0.5)}`,
'&:hover': {
textDecoration: 'none',
'&$disabled': {
backgroundColor: 'transparent',
},
border: `1px solid ${theme.palette.primary.main}`,
backgroundColor: fade(
theme.palette.primary.main,
theme.palette.action.hoverOpacity,
),
// Reset on touch devices, it doesn't add specificity
'@media (hover: none)': {
backgroundColor: 'transparent',
},
},
'&$disabled': {
color: theme.palette.action.disabled,
},
},
'& .asyncapi__collapse-button:hover': {
color: theme.palette.primary.main,
},
'& button.asyncapi__toggle-button': {
'min-width': 'inherit',
},
'& .asyncapi__info-list li': {
'border-color': theme.palette.primary.main,
'&:hover': {
color: theme.palette.text.primary,
'border-color': theme.palette.primary.main,
'background-color': theme.palette.primary.main,
},
},
'& .asyncapi__info-list li a': {
color: theme.palette.primary.main,
'&:hover': {
color: theme.palette.getContrastText(theme.palette.primary.main),
},
},
'& .asyncapi__enum': {
color: theme.palette.secondary.main,
},
'& .asyncapi__toggle-arrow:before': {
content: '">"',
'font-family': 'inherit',
},
'& .asyncapi__anchor-icon:before': {
content: '"🔗"',
'font-family': 'inherit',
},
'& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div': {
'background-color': 'inherit',
},
'& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header': {
'background-color': 'inherit',
color: theme.palette.text.primary,
},
'& .asyncapi__additional-properties-notice': {
color: theme.palette.text.hint,
},
'& .asyncapi__code, .asyncapi__code-pre': {
background: theme.palette.background.default,
},
'& .asyncapi__schema-example-header-title': {
color: theme.palette.text.secondary,
},
'& .asyncapi__message-headers-header, .asyncapi__message-payload-header, .asyncapi__server-variables-header, .asyncapi__server-security-header': {
'background-color': 'inherit',
color: theme.palette.text.secondary,
},
'& .asyncapi__table-header': {
background: theme.palette.background.default,
},
'& .asyncapi__table-body': {
color: theme.palette.text.primary,
},
'& .asyncapi__server-security-flow': {
background: theme.palette.background.default,
border: 'none',
},
'& .asyncapi__server-security-flows-list a': {
color: theme.palette.primary.main,
},
'& .asyncapi__table-row--nested': {
color: theme.palette.text.secondary,
},
},
}));
type Props = {
definition: any;
};
export const AsyncApiDefinitionWidget = ({ definition }: Props) => {
const classes = useStyles();
return (
<div className={classes.root}>
<AsyncApi schema={definition} />
</div>
);
};
@@ -0,0 +1,89 @@
/*
* 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 React, { useEffect, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
// TODO: Schemas
const useStyles = makeStyles(theme => ({
root: {
'& .swagger-ui, .info h1, .info h2, .info h3, .info h4, .info h': {
'font-family': 'inherit',
color: theme.palette.text.primary,
},
'& .scheme-container': {
'background-color': theme.palette.background.default,
},
'& .opblock-tag, .opblock-tag small, table thead tr td, table thead tr th': {
color: theme.palette.text.primary,
'border-color': theme.palette.divider,
},
'& section.models, section.models.is-open h4': {
'border-color': theme.palette.divider,
},
'& .opblock .opblock-summary-description, .parameter__type, table.headers td, .model-title, .model .property.primitive, section h3': {
color: theme.palette.text.secondary,
},
'& .opblock .opblock-summary-operation-id, .opblock .opblock-summary-path, .opblock .opblock-summary-path__deprecated, .opblock .opblock-section-header h4, .parameter__name, .response-col_status, .response-col_links, .responses-inner h4, .swagger-ui .responses-inner h5, .opblock-section-header .btn, .tab li, .info li, .info p, .info table, section.models h4, .info .title, table.model tr.description, .property-row': {
color: theme.palette.text.primary,
},
'& .opblock .opblock-section-header, .model-box, section.models .model-container': {
background: theme.palette.background.default,
},
'& .prop-format, .parameter__in': {
color: theme.palette.text.disabled,
},
'& ': {
color: theme.palette.text.primary,
'border-color': theme.palette.divider,
},
'& .opblock-description-wrapper p, .opblock-external-docs-wrapper p, .opblock-title_normal p, .response-control-media-type__accept-message, .opblock .opblock-section-header>label, .scheme-container .schemes>label, .info .base-url, .model': {
color: theme.palette.text.hint,
},
'& .parameter__name.required:after': {
color: theme.palette.warning.dark,
},
'& .prop-type': {
color: theme.palette.primary.main,
},
},
}));
type Props = {
definition: any;
};
export const OpenApiDefinitionWidget = ({ definition }: Props) => {
const classes = useStyles();
// Due to a bug in the swagger-ui-react component, the component needs
// to be created without content first.
const [def, setDef] = useState('');
useEffect(() => {
const timer = setTimeout(() => setDef(definition), 0);
return () => clearTimeout(timer);
}, [definition, setDef]);
return (
<div className={classes.root}>
<SwaggerUI spec={def} />
</div>
);
};
@@ -0,0 +1,27 @@
/*
* 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 { CodeSnippet } from '@backstage/core';
import React from 'react';
type Props = {
definition: any;
language: string;
};
export const PlainApiDefinitionWidget = ({ definition, language }: Props) => {
return <CodeSnippet text={definition} language={language} />;
};
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ApiDefinitionCard } from './components/ApiDefinitionCard/ApiDefinitionCard';
export { plugin } from './plugin';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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 { plugin } from './plugin';
describe('api-docs', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+28
View File
@@ -0,0 +1,28 @@
/*
* 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 { createPlugin } from '@backstage/core';
import { ApiCatalogPage } from './components/ApiCatalogPage/ApiCatalogPage';
import { ApiEntityPage } from './components/ApiEntityPage/ApiEntityPage';
import { entityRoute, rootRoute } from './routes';
export const plugin = createPlugin({
id: 'api-docs',
register({ router }) {
router.addRoute(rootRoute, ApiCatalogPage);
router.addRoute(entityRoute, ApiEntityPage);
},
});
+30
View File
@@ -0,0 +1,30 @@
/*
* 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 { createRouteRef } from '@backstage/core';
const NoIcon = () => null;
export const rootRoute = createRouteRef({
icon: NoIcon,
path: '/api-docs',
title: 'APIs',
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/api-docs/:optionalNamespaceAndName/',
title: 'API',
});
+19
View File
@@ -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.
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+32
View File
@@ -0,0 +1,32 @@
# App backend plugin
This backend plugin can be installed to serve static content of a Backstage app.
## Installation
Add both this package and your local frontend app package as dependencies to your backend, for example
```bash
yarn add @backstage/plugin-app-backend example-app
```
By adding the app package as a dependency we ensure that it is built as part of the backend, and that it can be resolved at runtime.
Now add the plugin router to your app, creating it for example like this:
```ts
const router = await createRouter({
logger,
appPackageName: 'example-app',
});
```
And registering it like this:
```ts
createServiceBuilder(module)
...
.addRouter('', router);
```
Be sure to register the app router last, as it serves content for HTML5-mode navigation, i.e. falling back to serving `index.html` for any route that can't be found.
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@backstage/plugin-app-backend",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.21",
"@backstage/config-loader": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.21",
"@types/supertest": "^2.0.8",
"msw": "^0.19.5",
"supertest": "^4.0.2"
},
"files": [
"dist",
"static"
]
}
@@ -13,4 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Widget } from './Widget';
export * from './service/router';
+136
View File
@@ -0,0 +1,136 @@
/*
* 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { getVoidLogger } from '@backstage/backend-common';
import { injectEnvConfig } from './config';
jest.mock('fs-extra');
const fsMock = fs as jest.Mocked<typeof fs>;
const readFileMock = (fsMock.readFile as unknown) as jest.MockedFunction<
(name: string) => Promise<string>
>;
const MOCK_DIR = 'mock-dir';
const baseOptions = {
env: {},
staticDir: MOCK_DIR,
logger: getVoidLogger(),
};
describe('injectEnvConfig', () => {
beforeEach(() => {
fsMock.readdir.mockResolvedValue(['main.js']);
});
afterEach(() => {
jest.resetAllMocks();
});
it('should not inject without config', async () => {
await injectEnvConfig(baseOptions);
expect(fsMock.readdir).toHaveBeenCalledTimes(0);
expect(fsMock.readFile).toHaveBeenCalledTimes(0);
expect(fsMock.writeFile).toHaveBeenCalledTimes(0);
});
it('should find the correct file to inject', async () => {
fsMock.readdir.mockResolvedValue([
'before.js',
'not-js.txt',
'main.js',
'after.js',
]);
readFileMock.mockImplementation(async (file: string) => {
if (file.endsWith('main.js')) {
return '"__APP_INJECTED_RUNTIME_CONFIG__"';
}
return 'NO_PLACEHOLDER_HERE';
});
await injectEnvConfig({ ...baseOptions, env: { APP_CONFIG_x: '0' } });
expect(fsMock.readFile).toHaveBeenCalledTimes(2);
expect(fsMock.readFile).toHaveBeenNthCalledWith(
1,
resolvePath(MOCK_DIR, 'before.js'),
'utf8',
);
expect(fsMock.readFile).toHaveBeenNthCalledWith(
2,
resolvePath(MOCK_DIR, 'main.js'),
'utf8',
);
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/',
'utf8',
);
// eslint-disable-next-line no-eval
expect(JSON.parse(eval(fsMock.writeFile.mock.calls[0][1]))).toEqual({
x: 0,
});
});
it('should re-inject config', async () => {
fsMock.readdir.mockResolvedValue(['main.js']);
readFileMock.mockResolvedValue(
'JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__")',
);
await injectEnvConfig({
...baseOptions,
env: {
APP_CONFIG_x: '0',
},
});
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/)',
'utf8',
);
// eslint-disable-next-line no-eval
expect(eval(fsMock.writeFile.mock.calls[0][1])).toEqual({ x: 0 });
readFileMock.mockResolvedValue(fsMock.writeFile.mock.calls[0][1]);
await injectEnvConfig({
...baseOptions,
env: {
APP_CONFIG_x: '1',
APP_CONFIG_y: '2',
},
});
expect(fsMock.writeFile).toHaveBeenCalledTimes(2);
expect(fsMock.writeFile).toHaveBeenLastCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":1,\\"y\\":2}"/*__INJECTED_END__*/)',
'utf8',
);
// eslint-disable-next-line no-eval
expect(eval(fsMock.writeFile.mock.calls[1][1])).toEqual({ x: 1, y: 2 });
});
});
+74
View File
@@ -0,0 +1,74 @@
/*
* 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { readEnvConfig } from '@backstage/config-loader';
import { Logger } from 'winston';
type Options = {
// Environment to read config from
env: { [name: string]: string | undefined };
// Directory of the static JS files to search for file to inject
staticDir: string;
logger: Logger;
};
/**
* Injects config from APP_CONFIG_ env vars, replacing existing
* injected config if it has already been injected.
*/
export async function injectEnvConfig(options: Options) {
const { env, staticDir, logger } = options;
const envConfig = readEnvConfig(env);
if (envConfig.length === 0) {
return;
}
const files = await fs.readdir(staticDir);
const jsFiles = files.filter(file => file.endsWith('.js'));
const [{ data }] = envConfig;
const escapedData = JSON.stringify(data).replace(/("|'|\\)/g, '\\$1');
const injected = `/*__APP_INJECTED_CONFIG_MARKER__*/"${escapedData}"/*__INJECTED_END__*/`;
for (const jsFile of jsFiles) {
const path = resolvePath(staticDir, jsFile);
const content = await fs.readFile(path, 'utf8');
if (content.includes('__APP_INJECTED_RUNTIME_CONFIG__')) {
logger.info(`Injecting env config into ${jsFile}`);
const newContent = content.replace(
'"__APP_INJECTED_RUNTIME_CONFIG__"',
injected,
);
await fs.writeFile(path, newContent, 'utf8');
return;
} else if (content.includes('__APP_INJECTED_CONFIG_MARKER__')) {
logger.info(`Replacing injected env config in ${jsFile}`);
const newContent = content.replace(
/\/\*__APP_INJECTED_CONFIG_MARKER__\*\/.*\/\*__INJECTED_END__\*\//,
injected,
);
await fs.writeFile(path, newContent, 'utf8');
return;
}
}
logger.info('Env config not injected');
}
+33
View File
@@ -0,0 +1,33 @@
/*
* 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1 @@
!dist
@@ -0,0 +1 @@
this is index.html
@@ -0,0 +1 @@
this is other.html
@@ -0,0 +1 @@
this is main.txt
@@ -0,0 +1,95 @@
/*
* 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 { resolve as resolvePath } from 'path';
import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import request from 'supertest';
import { createRouter } from './router';
jest.mock('../lib/config', () => ({ injectEnvConfig: jest.fn() }));
global.__non_webpack_require__ = {
resolve: () => resolvePath(__dirname, '__fixtures__/app-dir/package.json'),
};
describe('createRouter', () => {
let app: express.Express;
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
appPackageName: 'example-app',
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
it('returns index.html', async () => {
const response = await request(app).get('/index.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is index.html\n');
});
it('returns other.html', async () => {
const response = await request(app).get('/other.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is other.html\n');
});
it('returns index.html if missing', async () => {
const response = await request(app).get('/missing.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is index.html\n');
});
});
describe('createRouter with static fallback handler', () => {
it('uses static fallback handler', async () => {
const staticFallbackHandler = Router();
staticFallbackHandler.get('/test.txt', (_req, res) => {
res.end('this is test.txt');
});
const router = await createRouter({
logger: getVoidLogger(),
appPackageName: 'example-app',
staticFallbackHandler,
});
const app = express().use(router);
const response1 = await request(app).get('/static/main.txt');
expect(response1.status).toBe(200);
expect(response1.text).toBe('this is main.txt\n');
const response2 = await request(app).get('/static/test.txt');
expect(response2.status).toBe(200);
expect(response2.text).toBe('this is test.txt');
const response3 = await request(app).get('/static/missing.txt');
expect(response3.status).toBe(404);
});
});
+59
View File
@@ -0,0 +1,59 @@
/*
* 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 { resolve as resolvePath } from 'path';
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { injectEnvConfig } from '../lib/config';
export interface RouterOptions {
logger: Logger;
appPackageName: string;
staticFallbackHandler?: express.Handler;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const appDistDir = resolvePackagePath(options.appPackageName, 'dist');
options.logger.info(`Serving static app content from ${appDistDir}`);
await injectEnvConfig({
env: process.env,
logger: options.logger,
staticDir: resolvePath(appDistDir, 'static'),
});
const router = Router();
// Use a separate router for static content so that a fallback can be provided by backend
const staticRouter = Router();
staticRouter.use(express.static(resolvePath(appDistDir, 'static')));
if (options.staticFallbackHandler) {
staticRouter.use(options.staticFallbackHandler);
}
staticRouter.use(notFoundHandler());
router.use('/static', staticRouter);
router.use(express.static(appDistDir));
router.get('/*', (_req, res) => {
res.sendFile(resolvePath(appDistDir, 'index.html'));
});
return router;
}
@@ -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.
*/
import { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'app-backend' });
logger.debug('Starting application server...');
const router = await createRouter({
logger,
appPackageName: 'example-app',
});
const service = createServiceBuilder(module).addRouter('', router);
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
+17
View File
@@ -0,0 +1,17 @@
/*
* 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 {};
+44 -1
View File
@@ -66,6 +66,49 @@ export AUTH_OKTA_CLIENT_ID=x
export AUTH_OKTA_CLIENT_SECRET=x
```
### Auth0
```bash
export AUTH_AUTH0_DOMAIN=x
export AUTH_AUTH0_CLIENT_ID=x
export AUTH_AUTH0_CLIENT_SECRET=x
```
### Microsoft
#### Creating an Azure AD App Registration
An Azure AD App Registration is required to be able to sign in using Azure AD and the Microsoft Graph API.
Click [here](https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps) to create a new one.
- Click on the `New Registration` button.
- Give the app a name. e.g. `backstage-dev`
- Select `Accounts in this organizational directory only` under supported account types.
- Enter the callback URL for your backstage backend instance:
- For local development, this is likely `http://localhost:7000/auth/microsoft/handler/frame`
- For non-local deployments, this will be `https://{APP_FQDN}:{APP_BACKEND_PORT}/auth/microsoft/handler/frame`
- Click `Register`.
We also need to generate a client secret so Backstage can authenticate as this app.
- Click on the `Certificates & secrets` menu item.
- Under `Client secrets`, click on `New client secret`.
- Add a description for the new secret. e.g. `auth-backend-plugin`
- Select an expiry time; `1 Year`, `2 Years` or `Never`.
- Click `Add`.
The secret value will then be displayed on the screen. **You will not be able to retrieve it again after leaving the page**.
#### Starting the Auth Backend
```bash
cd packages/backend
export AUTH_MICROSOFT_CLIENT_ID=x
export AUTH_MICROSOFT_CLIENT_SECRET=x
export AUTH_MICROSOFT_TENANT_ID=x
yarn start
```
### SAML
To try out SAML, you can use the mock identity provider:
@@ -80,4 +123,4 @@ To try out SAML, you can use the mock identity provider:
## Links
- (The Backstage homepage)[https://backstage.io]
- [The Backstage homepage](https://backstage.io)
+7 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.1.1-alpha.18",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,16 +20,16 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.18",
"@backstage/config": "^0.1.1-alpha.18",
"@backstage/backend-common": "^0.1.1-alpha.21",
"@backstage/config": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"body-parser": "^1.19.0",
"compression": "^1.7.4",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"got": "^11.5.2",
"helmet": "^4.0.0",
"jose": "^1.27.1",
"jwt-decode": "2.2.0",
@@ -40,6 +40,7 @@
"passport-github2": "^0.1.12",
"passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0",
"passport-microsoft": "^0.1.0",
"passport-oauth2": "^1.5.0",
"passport-okta-oauth": "^0.0.1",
"passport-saml": "^1.3.3",
@@ -48,13 +49,14 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.18",
"@backstage/cli": "^0.1.1-alpha.21",
"@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-microsoft": "^0.0.0",
"@types/passport-saml": "^1.1.2",
"jest-fetch-mock": "^3.0.3"
},
@@ -15,13 +15,13 @@
*/
import Knex from 'knex';
import path from 'path';
import { utc } from 'moment';
import { resolvePackagePath } from '@backstage/backend-common';
import { AnyJWK, KeyStore, StoredKey } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-auth-backend/package.json'),
'../migrations',
const migrationsDir = resolvePackagePath(
'@backstage/plugin-auth-backend',
'migrations',
);
const TABLE = 'signing_keys';
@@ -1,75 +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 express from 'express';
import {
AuthProviderRouteHandlers,
EnvironmentIdentifierFn,
} from '../providers/types';
export type EnvironmentHandlers = {
[key: string]: AuthProviderRouteHandlers;
};
export class EnvironmentHandler implements AuthProviderRouteHandlers {
constructor(
private readonly providerId: string,
private readonly providers: EnvironmentHandlers,
private readonly envIdentifier: EnvironmentIdentifierFn,
) {}
private getProviderForEnv(
req: express.Request,
res: express.Response,
): AuthProviderRouteHandlers | undefined {
const env: string | undefined = this.envIdentifier(req);
if (env && this.providers.hasOwnProperty(env)) {
return this.providers[env];
}
res.status(404).send(
`Missing configuration.
<br>
<br>
For this flow to work you need to supply a valid configuration for the "${env}" environment of the "${this.providerId}" provider.`,
);
return undefined;
}
async start(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req, res);
await provider?.start(req, res);
}
async frameHandler(
req: express.Request,
res: express.Response,
): Promise<void> {
const provider = this.getProviderForEnv(req, res);
await provider?.frameHandler(req, res);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req, res);
await provider?.refresh?.(req, res);
}
async logout(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req, res);
await provider?.logout?.(req, res);
}
}
@@ -0,0 +1,103 @@
/*
* 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 express from 'express';
import { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
import { WebMessageResponse } from './types';
describe('oauth helpers', () => {
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
},
},
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
it('should post a message back with payload error', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
error: new Error('Unknown error occured'),
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
});
describe('ensuresXRequestedWith', () => {
it('should return false if no header present', () => {
const mockRequest = ({
header: () => jest.fn(),
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return false if header present with incorrect value', () => {
const mockRequest = ({
header: () => 'INVALID',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return true if header present with correct value', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
});
});
});
@@ -0,0 +1,56 @@
/*
* 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 express from 'express';
import crypto from 'crypto';
import { WebMessageResponse } from './types';
export const postMessageResponse = (
res: express.Response,
appOrigin: string,
response: WebMessageResponse,
) => {
const jsonData = JSON.stringify(response);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
res.setHeader('Content-Type', 'text/html');
res.setHeader('X-Frame-Options', 'sameorigin');
// TODO: Make target app origin configurable globally
const script = `
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
window.close()
`;
const hash = crypto.createHash('sha256').update(script).digest('base64');
res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`);
res.end(`
<html>
<body>
<script>${script}</script>
</body>
</html>
`);
};
export const ensuresXRequestedWith = (req: express.Request) => {
const requiredHeader = req.header('X-Requested-With');
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
return false;
}
return true;
};
@@ -0,0 +1,17 @@
/*
* 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 { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
@@ -0,0 +1,31 @@
/*
* 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 { AuthResponse } from '../../providers/types';
/**
* 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 WebMessageResponse =
| {
type: 'authorization_response';
response: AuthResponse<unknown>;
}
| {
type: 'authorization_response';
error: Error;
};
@@ -15,16 +15,9 @@
*/
import express from 'express';
import {
ensuresXRequestedWith,
postMessageResponse,
THOUSAND_DAYS_MS,
TEN_MINUTES_MS,
verifyNonce,
encodeState,
OAuthProvider,
} from './OAuthProvider';
import { WebMessageResponse, OAuthProviderHandlers } from '../providers/types';
import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter';
import { encodeState } from './helpers';
import { OAuthHandlers } from './types';
const mockResponseData = {
providerInfo: {
@@ -41,149 +34,8 @@ const mockResponseData = {
},
};
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
it('should throw error if cookie nonce missing', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Auth response is missing cookie nonce');
});
it('should throw error if state nonce missing', () => {
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid state passed via request');
});
it('should throw error if nonce mismatch', () => {
const state = { nonce: 'NONCEB', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCEA',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid nonce');
});
it('should not throw any error if nonce matches', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).not.toThrow();
});
});
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
},
},
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
it('should post a message back with payload error', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
error: new Error('Unknown error occured'),
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
});
describe('ensuresXRequestedWith', () => {
it('should return false if no header present', () => {
const mockRequest = ({
header: () => jest.fn(),
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return false if header present with incorrect value', () => {
const mockRequest = ({
header: () => 'INVALID',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return true if header present with correct value', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
});
});
});
describe('OAuthProvider', () => {
class MyAuthProvider implements OAuthProviderHandlers {
describe('OAuthAdapter', () => {
class MyAuthProvider implements OAuthHandlers {
async start() {
return {
url: '/url',
@@ -205,8 +57,9 @@ describe('OAuthProvider', () => {
providerId: 'test-provider',
secure: false,
disableRefresh: true,
baseUrl: 'http://localhost:7000/auth',
appOrigin: 'http://localhost:3000',
cookieDomain: 'localhost',
cookiePath: '/auth/test-provider',
tokenIssuer: {
issueToken: async () => 'my-id-token',
listPublicKeys: async () => ({ keys: [] }),
@@ -214,7 +67,7 @@ describe('OAuthProvider', () => {
};
it('sets the correct headers in start', async () => {
const oauthProvider = new OAuthProvider(
const oauthProvider = new OAuthAdapter(
providerInstance,
oAuthProviderOptions,
);
@@ -249,7 +102,7 @@ describe('OAuthProvider', () => {
});
it('sets the refresh cookie if refresh is enabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
@@ -283,7 +136,7 @@ describe('OAuthProvider', () => {
});
it('does not set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
@@ -308,7 +161,7 @@ describe('OAuthProvider', () => {
});
it('removes refresh cookie when logging out', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
@@ -333,7 +186,7 @@ describe('OAuthProvider', () => {
it('gets new access-token when refreshing', async () => {
oAuthProviderOptions.disableRefresh = false;
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
@@ -362,7 +215,7 @@ describe('OAuthProvider', () => {
});
it('handles refresh without capabilities', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
@@ -19,13 +19,14 @@ import crypto from 'crypto';
import { URL } from 'url';
import {
AuthProviderRouteHandlers,
OAuthProviderHandlers,
WebMessageResponse,
BackstageIdentity,
OAuthState,
} from '../providers/types';
AuthProviderConfig,
} from '../../providers/types';
import { InputError } from '@backstage/backend-common';
import { TokenIssuer } from '../identity';
import { TokenIssuer } from '../../identity';
import { verifyNonce, encodeState } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
import { OAuthHandlers } from './types';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
@@ -35,102 +36,38 @@ export type Options = {
secure: boolean;
disableRefresh?: boolean;
persistScopes?: boolean;
baseUrl: string;
cookieDomain: string;
cookiePath: string;
appOrigin: string;
tokenIssuer: TokenIssuer;
};
const readState = (stateString: string): OAuthState => {
const state = Object.fromEntries(
new URLSearchParams(decodeURIComponent(stateString)),
);
if (
!state.nonce ||
!state.env ||
state.nonce?.length === 0 ||
state.env?.length === 0
) {
throw Error(`Invalid state passed via request`);
export class OAuthAdapter implements AuthProviderRouteHandlers {
static fromConfig(
config: AuthProviderConfig,
handlers: OAuthHandlers,
options: Pick<
Options,
'providerId' | 'persistScopes' | 'disableRefresh' | 'tokenIssuer'
>,
): OAuthAdapter {
const { origin: appOrigin } = new URL(config.appUrl);
const secure = config.baseUrl.startsWith('https://');
const url = new URL(config.baseUrl);
const cookiePath = `${url.pathname}/${options.providerId}`;
return new OAuthAdapter(handlers, {
...options,
appOrigin,
cookieDomain: url.hostname,
cookiePath,
secure,
});
}
return {
nonce: state.nonce,
env: state.env,
};
};
export const encodeState = (state: OAuthState): string => {
const searchParams = new URLSearchParams();
searchParams.append('nonce', state.nonce);
searchParams.append('env', state.env);
return encodeURIComponent(searchParams.toString());
};
export const verifyNonce = (req: express.Request, providerId: string) => {
const cookieNonce = req.cookies[`${providerId}-nonce`];
const state: OAuthState = readState(req.query.state?.toString() ?? '');
const stateNonce = state.nonce;
if (!cookieNonce) {
throw new Error('Auth response is missing cookie nonce');
}
if (stateNonce.length === 0) {
throw new Error('Auth response is missing state nonce');
}
if (cookieNonce !== stateNonce) {
throw new Error('Invalid nonce');
}
};
export const postMessageResponse = (
res: express.Response,
appOrigin: string,
response: WebMessageResponse,
) => {
const jsonData = JSON.stringify(response);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
res.setHeader('Content-Type', 'text/html');
res.setHeader('X-Frame-Options', 'sameorigin');
// TODO: Make target app origin configurable globally
const script = `
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
window.close()
`;
const hash = crypto.createHash('sha256').update(script).digest('base64');
res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`);
res.end(`
<html>
<body>
<script>${script}</script>
</body>
</html>
`);
};
export const ensuresXRequestedWith = (req: express.Request) => {
const requiredHeader = req.header('X-Requested-With');
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
return false;
}
return true;
};
export class OAuthProvider implements AuthProviderRouteHandlers {
private readonly domain: string;
private readonly basePath: string;
constructor(
private readonly providerHandlers: OAuthProviderHandlers,
private readonly handlers: OAuthHandlers,
private readonly options: Options,
) {
const url = new URL(options.baseUrl);
this.domain = url.hostname;
this.basePath = url.pathname;
}
) {}
async start(req: express.Request, res: express.Response): Promise<void> {
// retrieve scopes from request
@@ -157,10 +94,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
state: stateParameter,
};
const { url, status } = await this.providerHandlers.start(
req,
queryParameters,
);
const { url, status } = await this.handlers.start(req, queryParameters);
res.statusCode = status || 302;
res.setHeader('Location', url);
@@ -176,9 +110,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
// verify nonce cookie and state cookie on callback
verifyNonce(req, this.options.providerId);
const { response, refreshToken } = await this.providerHandlers.handler(
req,
);
const { response, refreshToken } = await this.handlers.handler(req);
if (this.options.persistScopes) {
const grantedScopes = this.getScopesFromCookie(
@@ -235,7 +167,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return;
}
if (!this.providerHandlers.refresh || this.options.disableRefresh) {
if (!this.handlers.refresh || this.options.disableRefresh) {
res.send(
`Refresh token not supported for provider: ${this.options.providerId}`,
);
@@ -254,29 +186,23 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
const scope = req.query.scope?.toString() ?? '';
// get new access_token
const response = await this.providerHandlers.refresh(refreshToken, scope);
const response = await this.handlers.refresh(refreshToken, scope);
await this.populateIdentity(response.backstageIdentity);
if (
response.providerInfo.refreshToken &&
response.providerInfo.refreshToken !== refreshToken
) {
this.setRefreshTokenCookie(res, response.providerInfo.refreshToken);
}
res.send(response);
} catch (error) {
res.status(401).send(`${error.message}`);
}
}
identifyEnv(req: express.Request): string | undefined {
const reqEnv = req.query.env?.toString();
if (reqEnv) {
return reqEnv;
}
const stateParams = req.query.state?.toString();
if (!stateParams) {
return undefined;
}
const env = readState(stateParams).env;
return env;
}
/**
* If the response from the OAuth provider includes a Backstage identity, we
* make sure it's populated with all the information we can derive from the user ID.
@@ -298,8 +224,8 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
maxAge: TEN_MINUTES_MS,
secure: this.options.secure,
sameSite: 'lax',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}/handler`,
domain: this.options.cookieDomain,
path: `${this.options.cookiePath}/handler`,
httpOnly: true,
});
};
@@ -309,8 +235,8 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
maxAge: TEN_MINUTES_MS,
secure: this.options.secure,
sameSite: 'lax',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}/handler`,
domain: this.options.cookieDomain,
path: `${this.options.cookiePath}/handler`,
httpOnly: true,
});
};
@@ -327,8 +253,8 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
maxAge: THOUSAND_DAYS_MS,
secure: this.options.secure,
sameSite: 'lax',
domain: this.domain,
path: `${this.basePath}/${this.options.providerId}`,
domain: this.options.cookieDomain,
path: this.options.cookiePath,
httpOnly: true,
});
};
@@ -336,10 +262,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
private removeRefreshTokenCookie = (res: express.Response) => {
res.cookie(`${this.options.providerId}-refresh-token`, '', {
maxAge: 0,
secure: false,
secure: this.options.secure,
sameSite: 'lax',
domain: `${this.domain}`,
path: `${this.basePath}/${this.options.providerId}`,
domain: this.options.cookieDomain,
path: this.options.cookiePath,
httpOnly: true,
});
};
@@ -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 express from 'express';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/backend-common';
import { readState } from './helpers';
import { AuthProviderRouteHandlers } from '../../providers/types';
export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
static mapConfig(
config: Config,
factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers,
) {
const envs = config.keys();
const handlers = new Map<string, AuthProviderRouteHandlers>();
for (const env of envs) {
const envConfig = config.getConfig(env);
const handler = factoryFunc(envConfig);
handlers.set(env, handler);
}
return new OAuthEnvironmentHandler(handlers);
}
constructor(
private readonly handlers: Map<string, AuthProviderRouteHandlers>,
) {}
async start(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req, res);
await provider?.start(req, res);
}
async frameHandler(
req: express.Request,
res: express.Response,
): Promise<void> {
const provider = this.getProviderForEnv(req, res);
await provider?.frameHandler(req, res);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req, res);
await provider?.refresh?.(req, res);
}
async logout(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req, res);
await provider?.logout?.(req, res);
}
private getRequestFromEnv(req: express.Request): string | undefined {
const reqEnv = req.query.env?.toString();
if (reqEnv) {
return reqEnv;
}
const stateParams = req.query.state?.toString();
if (!stateParams) {
return undefined;
}
const env = readState(stateParams).env;
return env;
}
private getProviderForEnv(
req: express.Request,
res: express.Response,
): AuthProviderRouteHandlers | undefined {
const env: string | undefined = this.getRequestFromEnv(req);
if (!env) {
throw new InputError(`Must specify 'env' query to select environment`);
}
if (!this.handlers.has(env)) {
res.status(404).send(
`Missing configuration.
<br>
<br>
For this flow to work you need to supply a valid configuration for the "${env}" environment of provider.`,
);
return undefined;
}
return this.handlers.get(env);
}
}
@@ -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 express from 'express';
import { verifyNonce, encodeState } from './helpers';
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
it('should throw error if cookie nonce missing', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Auth response is missing cookie nonce');
});
it('should throw error if state nonce missing', () => {
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid state passed via request');
});
it('should throw error if nonce mismatch', () => {
const state = { nonce: 'NONCEB', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCEA',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid nonce');
});
it('should not throw any error if nonce matches', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).not.toThrow();
});
});
});
@@ -0,0 +1,60 @@
/*
* 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 express from 'express';
import { OAuthState } from './types';
export const readState = (stateString: string): OAuthState => {
const state = Object.fromEntries(
new URLSearchParams(decodeURIComponent(stateString)),
);
if (
!state.nonce ||
!state.env ||
state.nonce?.length === 0 ||
state.env?.length === 0
) {
throw Error(`Invalid state passed via request`);
}
return {
nonce: state.nonce,
env: state.env,
};
};
export const encodeState = (state: OAuthState): string => {
const searchParams = new URLSearchParams();
searchParams.append('nonce', state.nonce);
searchParams.append('env', state.env);
return encodeURIComponent(searchParams.toString());
};
export const verifyNonce = (req: express.Request, providerId: string) => {
const cookieNonce = req.cookies[`${providerId}-nonce`];
const state: OAuthState = readState(req.query.state?.toString() ?? '');
const stateNonce = state.nonce;
if (!cookieNonce) {
throw new Error('Auth response is missing cookie nonce');
}
if (stateNonce.length === 0) {
throw new Error('Auth response is missing state nonce');
}
if (cookieNonce !== stateNonce) {
throw new Error('Invalid nonce');
}
};
@@ -0,0 +1,25 @@
/*
* 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 { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler';
export { OAuthAdapter } from './OAuthAdapter';
export type {
OAuthHandlers,
OAuthProviderInfo,
OAuthProviderOptions,
OAuthResponse,
OAuthState,
} from './types';
+111
View File
@@ -0,0 +1,111 @@
/*
* 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 express from 'express';
import { AuthResponse, RedirectInfo } from '../../providers/types';
/**
* Common options for passport.js-based OAuth providers
*/
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 OAuthResponse = AuthResponse<OAuthProviderInfo>;
export type OAuthProviderInfo = {
/**
* 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;
/**
* A refresh token issued for the signed in user
*/
refreshToken?: string;
};
export type OAuthState = {
/* A type for the serialized value in the `state` parameter of the OAuth authorization flow
*/
nonce: string;
env: 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 OAuthHandlers {
/**
* This method initiates a sign in request with an auth provider.
* @param {express.Request} req
* @param options
*/
start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo>;
/**
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
*/
handler(
req: express.Request,
): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
/**
* (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<AuthResponse<OAuthProviderInfo>>;
/**
* (Optional) Sign out of the auth provider.
*/
logout?(): Promise<void>;
}
@@ -17,12 +17,13 @@
import express from 'express';
import passport from 'passport';
import jwtDecoder from 'jwt-decode';
import {
RedirectInfo,
RefreshTokenResponse,
ProfileInfo,
ProviderStrategy,
} from '../providers/types';
import { ProfileInfo, RedirectInfo } from '../../providers/types';
export type PassportDoneCallback<Res, Private = never> = (
err?: Error,
response?: Res,
privateInfo?: Private,
) => void;
export const makeProfileInfo = (
profile: passport.Profile,
@@ -45,7 +46,6 @@ export const makeProfileInfo = (
if ((!email || !picture) && idToken) {
try {
const decoded: Record<string, string> = jwtDecoder(idToken);
if (!email && decoded.email) {
email = decoded.email;
}
@@ -107,6 +107,18 @@ export const executeFrameHandlerStrategy = async <T, PrivateInfo = never>(
);
};
type RefreshTokenResponse = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* Optionally, the server can issue a new Refresh Token for the user
*/
refreshToken?: string;
params: any;
};
export const executeRefreshTokenStrategy = async (
providerStrategy: passport.Strategy,
refreshToken: string,
@@ -133,7 +145,7 @@ export const executeRefreshTokenStrategy = async (
(
err: Error | null,
accessToken: string,
_refreshToken: string,
newRefreshToken: string,
params: any,
) => {
if (err) {
@@ -149,6 +161,7 @@ export const executeRefreshTokenStrategy = async (
resolve({
accessToken,
refreshToken: newRefreshToken,
params,
});
},
@@ -156,6 +169,10 @@ export const executeRefreshTokenStrategy = async (
});
};
type ProviderStrategy = {
userProfile(accessToken: string, callback: Function): void;
};
export const executeFetchUserProfileStrategy = async (
providerStrategy: passport.Strategy,
accessToken: string,
@@ -0,0 +1,24 @@
/*
* 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 {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from './PassportStrategyHelper';
export type { PassportDoneCallback } from './PassportStrategyHelper';
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { WorkflowRunsPage } from './WorkflowRunsPage';
export { createAuth0Provider } from './provider';
@@ -0,0 +1,175 @@
/*
* 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 express from 'express';
import passport from 'passport';
import Auth0Strategy from './strategy';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
type PrivateInfo = {
refreshToken: string;
};
export type Auth0AuthProviderOptions = OAuthProviderOptions & {
domain: string;
};
export class Auth0AuthProvider implements OAuthHandlers {
private readonly _strategy: Auth0Strategy;
constructor(options: Auth0AuthProviderOptions) {
this._strategy = new Auth0Strategy(
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
domain: options.domain,
passReqToCallback: false as true,
},
(
accessToken: any,
refreshToken: any,
params: any,
rawProfile: passport.Profile,
done: PassportDoneCallback<OAuthResponse, PrivateInfo>,
) => {
const profile = makeProfileInfo(rawProfile, params.id_token);
done(
undefined,
{
providerInfo: {
idToken: params.id_token,
accessToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
},
profile,
},
{
refreshToken,
},
);
},
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
const providerOptions = {
...options,
accessType: 'offline',
prompt: 'consent',
};
return await executeRedirectStrategy(req, this._strategy, providerOptions);
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
const { response, privateInfo } = await executeFrameHandlerStrategy<
OAuthResponse,
PrivateInfo
>(req, this._strategy);
return {
response: await this.populateIdentity(response),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
);
const profile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
params.id_token,
);
return this.populateIdentity({
providerInfo: {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
});
}
// Use this function to grab the user profile info from the token
// Then populate the profile with it
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
if (!profile.email) {
throw new Error('Profile does not contain a profile');
}
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
}
}
export const createAuth0Provider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'auth0';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const domain = envConfig.getString('domain');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new Auth0AuthProvider({
clientId,
clientSecret,
callbackUrl,
domain,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
providerId,
tokenIssuer,
});
});
@@ -0,0 +1,40 @@
/*
* 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 OAuth2Strategy from 'passport-oauth2';
export interface Auth0StrategyOptionsWithRequest {
clientID: string;
clientSecret: string;
callbackURL: string;
domain: string;
passReqToCallback: true;
}
export default class Auth0Strategy extends OAuth2Strategy {
constructor(
options: Auth0StrategyOptionsWithRequest,
verify: OAuth2Strategy.VerifyFunctionWithRequest,
) {
const optionsWithURLs = {
...options,
authorizationURL: `https://${options.domain}/authorize`,
tokenURL: `https://${options.domain}/oauth/token`,
userInfoURL: `https://${options.domain}/userinfo`,
apiUrl: `https://${options.domain}/api`,
};
super(optionsWithURLs, verify);
}
}
+11 -14
View File
@@ -23,16 +23,10 @@ import { createGoogleProvider } from './google';
import { createOAuth2Provider } from './oauth2';
import { createOktaProvider } from './okta';
import { createSamlProvider } from './saml';
import {
AuthProviderConfig,
AuthProviderFactory,
EnvironmentIdentifierFn,
} from './types';
import { createAuth0Provider } from './auth0';
import { createMicrosoftProvider } from './microsoft';
import { AuthProviderConfig, AuthProviderFactory } from './types';
import { Config } from '@backstage/config';
import {
EnvironmentHandlers,
EnvironmentHandler,
} from '../lib/EnvironmentHandler';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -40,15 +34,17 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
gitlab: createGitlabProvider,
saml: createSamlProvider,
okta: createOktaProvider,
auth0: createAuth0Provider,
microsoft: createMicrosoftProvider,
oauth2: createOAuth2Provider,
};
export const createAuthProviderRouter = (
providerId: string,
globalConfig: AuthProviderConfig,
providerConfig: Config,
config: Config,
logger: Logger,
issuer: TokenIssuer,
tokenIssuer: TokenIssuer,
) => {
const factory = factories[providerId];
if (!factory) {
@@ -56,10 +52,8 @@ export const createAuthProviderRouter = (
}
const router = Router();
const envs = providerConfig.keys();
const envProviders: EnvironmentHandlers = {};
let envIdentifier: EnvironmentIdentifierFn | undefined;
<<<<<<< HEAD
for (const env of envs) {
const envConfig = providerConfig.getConfig(env);
console.log(envConfig);
@@ -79,6 +73,9 @@ export const createAuthProviderRouter = (
envProviders,
envIdentifier,
);
=======
const handler = factory({ globalConfig, config, logger, tokenIssuer });
>>>>>>> master
router.get('/start', handler.start.bind(handler));
router.get('/handler/frame', handler.frameHandler.bind(handler));
@@ -20,22 +20,25 @@ import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthProviderConfig,
RedirectInfo,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import passport from 'passport';
import { Config } from '@backstage/config';
export class GithubAuthProvider implements OAuthProviderHandlers {
export type GithubAuthProviderOptions = OAuthProviderOptions & {
tokenUrl?: string;
userProfileUrl?: string;
authorizationUrl?: string;
};
export class GithubAuthProvider implements OAuthHandlers {
private readonly _strategy: GithubStrategy;
static transformPassportProfile(rawProfile: any): passport.Profile {
@@ -68,7 +71,7 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
idToken: params.id_token,
};
// Github provides an id numeric value (123)
// GitHub provides an id numeric value (123)
// as a fallback
const id = passportProfile!.id;
@@ -87,9 +90,16 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
};
}
constructor(options: OAuthProviderOptions) {
constructor(options: GithubAuthProviderOptions) {
this._strategy = new GithubStrategy(
{ ...options },
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
tokenURL: options.tokenUrl,
userProfileURL: options.userProfileUrl,
authorizationURL: options.authorizationUrl,
},
(
accessToken: any,
_: any,
@@ -124,60 +134,42 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
}
}
export function createGithubProvider(
{ baseUrl }: AuthProviderConfig,
_: string,
envConfig: Config,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'github';
const secure = envConfig.getBoolean('secure');
const appOrigin = envConfig.getString('appOrigin');
const clientID = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const enterpriseInstanceUrl = envConfig.getOptionalString(
'enterpriseInstanceUrl',
);
const authorizationURL = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/authorize`
: undefined;
const tokenURL = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/access_token`
: undefined;
const userProfileURL = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/api/v3/user`
: undefined;
const callbackURL = `${baseUrl}/${providerId}/handler/frame`;
const opts = {
clientID,
clientSecret,
authorizationURL,
tokenURL,
userProfileURL,
callbackURL,
};
if (!opts.clientID || !opts.clientSecret) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Github auth provider, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars',
);
}
logger.warn(
'Github auth provider disabled, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars to enable',
export const createGithubProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'github';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const enterpriseInstanceUrl = envConfig.getOptionalString(
'enterpriseInstanceUrl',
);
return undefined;
}
return new OAuthProvider(new GithubAuthProvider(opts), {
disableRefresh: true,
persistScopes: true,
providerId,
secure,
baseUrl,
appOrigin,
tokenIssuer,
const authorizationUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/authorize`
: undefined;
const tokenUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/access_token`
: undefined;
const userProfileUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/api/v3/user`
: undefined;
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new GithubAuthProvider({
clientId,
clientSecret,
callbackUrl,
tokenUrl,
userProfileUrl,
authorizationUrl,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
persistScopes: true,
providerId,
tokenIssuer,
});
});
}
@@ -20,22 +20,23 @@ import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthProviderConfig,
RedirectInfo,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import passport from 'passport';
import { Config } from '@backstage/config';
export class GitlabAuthProvider implements OAuthProviderHandlers {
export type GitlabAuthProviderOptions = OAuthProviderOptions & {
baseUrl: string;
};
export class GitlabAuthProvider implements OAuthHandlers {
private readonly _strategy: GitlabStrategy;
static transformPassportProfile(rawProfile: any): passport.Profile {
@@ -96,9 +97,14 @@ export class GitlabAuthProvider implements OAuthProviderHandlers {
};
}
constructor(options: OAuthProviderOptions) {
constructor(options: GitlabAuthProviderOptions) {
this._strategy = new GitlabStrategy(
{ ...options },
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
baseURL: options.baseUrl,
},
(
accessToken: any,
_: any,
@@ -131,47 +137,29 @@ export class GitlabAuthProvider implements OAuthProviderHandlers {
}
}
export function createGitlabProvider(
{ baseUrl }: AuthProviderConfig,
_: string,
envConfig: Config,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'gitlab';
const secure = envConfig.getBoolean('secure');
const appOrigin = envConfig.getString('appOrigin');
const clientID = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const baseURL = audience || 'https://gitlab.com';
const callbackURL = `${baseUrl}/${providerId}/handler/frame`;
export const createGitlabProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'gitlab';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const baseUrl = audience || 'https://gitlab.com';
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const opts = {
clientID,
clientSecret,
callbackURL,
baseURL,
};
const provider = new GitlabAuthProvider({
clientId,
clientSecret,
callbackUrl,
baseUrl,
});
if (!opts.clientID || !opts.clientSecret) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Gitlab auth provider, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars',
);
}
logger.warn(
'Gitlab auth provider disabled, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars to enable',
);
return undefined;
}
return new OAuthProvider(new GitlabAuthProvider(opts), {
disableRefresh: true,
providerId,
secure,
baseUrl,
appOrigin,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
providerId,
tokenIssuer,
});
});
}
@@ -22,34 +22,36 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../../lib/PassportStrategyHelper';
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthProviderHandlers,
RedirectInfo,
AuthProviderConfig,
OAuthAdapter,
OAuthHandlers,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import passport from 'passport';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import { Config } from '@backstage/config';
type PrivateInfo = {
refreshToken: string;
};
export class GoogleAuthProvider implements OAuthProviderHandlers {
export class GoogleAuthProvider implements OAuthHandlers {
private readonly _strategy: GoogleStrategy;
constructor(options: OAuthProviderOptions) {
// TODO: throw error if env variables not set?
this._strategy = new GoogleStrategy(
// We need passReqToCallback set to false to get params, but there's
// no matching type signature for that, so instead behold this beauty
{ ...options, passReqToCallback: false as true },
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
// We need passReqToCallback set to false to get params, but there's
// no matching type signature for that, so instead behold this beauty
passReqToCallback: false as true,
},
(
accessToken: any,
refreshToken: any,
@@ -143,44 +145,26 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
}
}
export function createGoogleProvider(
{ baseUrl }: AuthProviderConfig,
_: string,
envConfig: Config,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'google';
const secure = envConfig.getBoolean('secure');
const appOrigin = envConfig.getString('appOrigin');
const clientID = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackURL = `${baseUrl}/${providerId}/handler/frame`;
export const createGoogleProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'google';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const opts = {
clientID,
clientSecret,
callbackURL,
};
const provider = new GoogleAuthProvider({
clientId,
clientSecret,
callbackUrl,
});
if (!opts.clientID || !opts.clientSecret) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Google auth provider, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars',
);
}
logger.warn(
'Google auth provider disabled, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars to enable',
);
return undefined;
}
return new OAuthProvider(new GoogleAuthProvider(opts), {
disableRefresh: false,
providerId,
secure,
baseUrl,
appOrigin,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
}
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { WorkflowRunDetailsPage } from './WorkflowRunDetailsPage';
export { createMicrosoftProvider } from './provider';
@@ -0,0 +1,234 @@
/*
* 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 express from 'express';
import passport from 'passport';
import { Strategy as MicrosoftStrategy } from 'passport-microsoft';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import got from 'got';
type PrivateInfo = {
refreshToken: string;
};
export type MicrosoftAuthProviderOptions = OAuthProviderOptions & {
authorizationUrl?: string;
tokenUrl?: string;
};
export class MicrosoftAuthProvider implements OAuthHandlers {
private readonly _strategy: MicrosoftStrategy;
static transformAuthResponse(
accessToken: string,
params: any,
rawProfile: any,
photoURL: any,
): OAuthResponse {
let passportProfile: passport.Profile = rawProfile;
passportProfile = {
...passportProfile,
photos: [{ value: photoURL }],
};
const profile = makeProfileInfo(passportProfile, params.id_token);
const providerInfo = {
idToken: params.id_token,
accessToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
};
return {
providerInfo,
profile,
};
}
constructor(options: MicrosoftAuthProviderOptions) {
this._strategy = new MicrosoftStrategy(
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
authorizationURL: options.authorizationUrl,
tokenURL: options.tokenUrl,
passReqToCallback: false as true,
},
(
accessToken: any,
refreshToken: any,
params: any,
rawProfile: passport.Profile,
done: PassportDoneCallback<OAuthResponse, PrivateInfo>,
) => {
this.getUserPhoto(accessToken)
.then(photoURL => {
const authResponse = MicrosoftAuthProvider.transformAuthResponse(
accessToken,
params,
rawProfile,
photoURL,
);
done(undefined, authResponse, { refreshToken });
})
.catch(error => {
throw new Error(`Error processing auth response: ${error}`);
});
},
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, options);
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
const { response, privateInfo } = await executeFrameHandlerStrategy<
OAuthResponse,
PrivateInfo
>(req, this._strategy);
return {
response: await this.populateIdentity(response),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
);
const profile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
params.id_token,
);
const photo = await this.getUserPhoto(accessToken);
if (photo) {
profile.picture = photo;
}
return this.populateIdentity({
providerInfo: {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
});
}
private getUserPhoto(accessToken: string): Promise<string> {
return new Promise(resolve => {
got
.get('https://graph.microsoft.com/v1.0/me/photos/48x48/$value', {
encoding: 'binary',
responseType: 'buffer',
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
.then(photoData => {
const photoURL = `data:image/jpeg;base64,${Buffer.from(
photoData.body,
).toString('base64')}`;
resolve(photoURL);
})
.catch(error => {
console.log(
`Could not retrieve user profile photo from Microsoft Graph API: ${error}`,
);
// User profile photo is optional, ignore errors and resolve undefined
resolve();
});
});
}
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
if (!profile.email) {
throw new Error('Microsoft profile contained no email');
}
// Like Google implementation, setting this to local part of email for now
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
}
}
export const createMicrosoftProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'microsoft';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const tenantID = envConfig.getString('tenantId');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`;
const tokenUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`;
const provider = new MicrosoftAuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
@@ -17,36 +17,45 @@
import express from 'express';
import passport from 'passport';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
GenericOAuth2ProviderOptions,
OAuthProviderHandlers,
OAuthResponse,
PassportDoneCallback,
RedirectInfo,
} from '../types';
import { Config } from '@backstage/config';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
type PrivateInfo = {
refreshToken: string;
};
export class OAuth2AuthProvider implements OAuthProviderHandlers {
export type OAuth2AuthProviderOptions = OAuthProviderOptions & {
authorizationUrl: string;
tokenUrl: string;
};
export class OAuth2AuthProvider implements OAuthHandlers {
private readonly _strategy: OAuth2Strategy;
constructor(options: GenericOAuth2ProviderOptions) {
constructor(options: OAuth2AuthProviderOptions) {
this._strategy = new OAuth2Strategy(
{ ...options, passReqToCallback: false as true },
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
authorizationURL: options.authorizationUrl,
tokenURL: options.tokenUrl,
passReqToCallback: false as true,
},
(
accessToken: any,
refreshToken: any,
@@ -55,6 +64,7 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers {
done: PassportDoneCallback<OAuthResponse, PrivateInfo>,
) => {
const profile = makeProfileInfo(rawProfile, params.id_token);
done(
undefined,
{
@@ -101,11 +111,16 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers {
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
const refreshTokenResponse = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
);
const {
accessToken,
params,
refreshToken: updatedRefreshToken,
} = refreshTokenResponse;
const profile = await executeFetchUserProfileStrategy(
this._strategy,
@@ -116,6 +131,7 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers {
return this.populateIdentity({
providerInfo: {
accessToken,
refreshToken: updatedRefreshToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
@@ -134,60 +150,36 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers {
if (!profile.email) {
throw new Error('Profile does not contain a profile');
}
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
}
}
export function createOAuth2Provider(
{ baseUrl }: AuthProviderConfig,
_: string,
envConfig: Config,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'oauth2';
const secure = envConfig.getBoolean('secure');
const appOrigin = envConfig.getString('appOrigin');
const clientID = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackURL = `${baseUrl}/${providerId}/handler/frame`;
const authorizationURL = envConfig.getString('authorizationURL');
const tokenURL = envConfig.getString('tokenURL');
export const createOAuth2Provider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'oauth2';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = envConfig.getString('authorizationUrl');
const tokenUrl = envConfig.getString('tokenUrl');
const opts = {
clientID,
clientSecret,
callbackURL,
authorizationURL,
tokenURL,
};
const provider = new OAuth2AuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
});
if (
!opts.clientID ||
!opts.clientSecret ||
!opts.authorizationURL ||
!opts.tokenURL
) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize OAuth2 auth provider, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars',
);
}
logger.warn(
'OAuth2 auth provider disabled, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars to enable',
);
return undefined;
}
return new OAuthProvider(new OAuth2AuthProvider(opts), {
disableRefresh: false,
providerId,
secure,
baseUrl,
appOrigin,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
}
@@ -14,7 +14,13 @@
* limitations under the License.
*/
import express from 'express';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import { Strategy as OktaStrategy } from 'passport-okta-oauth';
import passport from 'passport';
import {
@@ -23,25 +29,20 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
RedirectInfo,
AuthProviderConfig,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { Logger } from 'winston';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import { StateStore } from 'passport-oauth2';
import { TokenIssuer } from '../../identity';
import { Config } from '@backstage/config';
type PrivateInfo = {
refreshToken: string;
};
export class OktaAuthProvider implements OAuthProviderHandlers {
export type OktaAuthProviderOptions = OAuthProviderOptions & {
audience: string;
};
export class OktaAuthProvider implements OAuthHandlers {
private readonly _strategy: any;
/**
@@ -61,11 +62,14 @@ export class OktaAuthProvider implements OAuthProviderHandlers {
},
};
constructor(options: OAuthProviderOptions) {
constructor(options: OktaAuthProviderOptions) {
this._strategy = new OktaStrategy(
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
audience: options.audience,
passReqToCallback: false as true,
...options,
store: this._store,
response_type: 'code',
},
@@ -163,46 +167,28 @@ export class OktaAuthProvider implements OAuthProviderHandlers {
}
}
export function createOktaProvider(
{ baseUrl }: AuthProviderConfig,
_: string,
envConfig: Config,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'okta';
const secure = envConfig.getBoolean('secure');
const appOrigin = envConfig.getString('appOrigin');
const clientID = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const callbackURL = `${baseUrl}/${providerId}/handler/frame`;
export const createOktaProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'okta';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const opts = {
audience,
clientID,
clientSecret,
callbackURL,
};
const provider = new OktaAuthProvider({
audience,
clientId,
clientSecret,
callbackUrl,
});
if (!opts.clientID || !opts.clientSecret || !opts.audience) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Okta auth provider, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars',
);
}
logger.warn(
'Okta auth provider disabled, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars to enable',
);
return undefined;
}
return new OAuthProvider(new OktaAuthProvider(opts), {
disableRefresh: false,
providerId,
secure,
baseUrl,
appOrigin,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
}
@@ -23,17 +23,15 @@ import {
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
AuthProviderRouteHandlers,
PassportDoneCallback,
} from '../../lib/passport';
import {
AuthProviderRouteHandlers,
ProfileInfo,
AuthProviderFactory,
} from '../types';
import { postMessageResponse } from '../../lib/OAuthProvider';
import { Logger } from 'winston';
import { postMessageResponse } from '../../lib/flow';
import { TokenIssuer } from '../../identity';
import { Config } from '@backstage/config';
type SamlInfo = {
userId: string;
@@ -127,15 +125,12 @@ type SAMLProviderOptions = {
tokenIssuer: TokenIssuer;
};
export function createSamlProvider(
_authProviderConfig: AuthProviderConfig,
_env: string,
envConfig: Config,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const entryPoint = envConfig.getString('entryPoint');
const issuer = envConfig.getString('issuer');
export const createSamlProvider: AuthProviderFactory = ({
config,
tokenIssuer,
}) => {
const entryPoint = config.getString('entryPoint');
const issuer = config.getString('issuer');
const opts = {
entryPoint,
issuer,
@@ -143,11 +138,5 @@ export function createSamlProvider(
tokenIssuer,
};
if (!opts.entryPoint || !opts.issuer) {
logger.warn(
'SAML auth provider disabled, set entryPoint and entryPoint in saml auth config to enable',
);
return undefined;
}
return new SamlAuthProvider(opts);
}
};
+20 -212
View File
@@ -18,74 +18,6 @@ import express from 'express';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
import { Config } from '@backstage/config';
import { OAuthProvider } from '../lib/OAuthProvider';
import { SamlAuthProvider } from './saml/provider';
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 GenericOAuth2ProviderOptions = OAuthProviderOptions & {
authorizationURL: string;
tokenURL: 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;
/**
* 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;
/**
* The location of the OAuth Authorization Server
*/
audience?: string;
};
export type GenericOAuth2ProviderConfig = OAuthProviderConfig & {
authorizationURL: string;
tokenURL: string;
};
export type EnvironmentProviderConfig = {
/**
* key, values are environment names and OAuthProviderConfigs
*
* For e.g
* {
* development: DevelopmentOAuthProviderConfig
* production: ProductionOAuthProviderConfig
* }
*/
[key: string]: OAuthProviderConfig;
};
export type AuthProviderConfig = {
/**
@@ -93,50 +25,23 @@ export type AuthProviderConfig = {
* callbackURL to redirect to once the user signs in to the auth provider.
*/
baseUrl: string;
/**
* The base URL of the app as provided by app.baseUrl
*/
appUrl: 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 {
export type RedirectInfo = {
/**
* This method initiates a sign in request with an auth provider.
* @param {express.Request} req
* @param options
* URL to redirect to
*/
start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo>;
url: string;
/**
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
* Status code to use for the redirect
*/
handler(
req: express.Request,
): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
/**
* (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<AuthResponse<OAuthProviderInfo>>;
/**
* (Optional) Sign out of the auth provider.
*/
logout?(): Promise<void>;
}
status?: number;
};
/**
* Any Auth provider needs to implement this interface which handles the routes in the
@@ -203,24 +108,18 @@ export interface AuthProviderRouteHandlers {
* @param {express.Response} res
*/
logout?(req: express.Request, res: express.Response): Promise<void>;
/**
*(Optional) A method to identify the environment Context of the Request
*
*Request
*- contains the environment context information encoded in the request
* @param {express.Request} req
*/
identifyEnv?(req: express.Request): string | undefined;
}
export type AuthProviderFactoryOptions = {
globalConfig: AuthProviderConfig;
config: Config;
logger: Logger;
tokenIssuer: TokenIssuer;
};
export type AuthProviderFactory = (
globalConfig: AuthProviderConfig,
env: string,
envConfig: Config,
logger: Logger,
issuer: TokenIssuer,
) => OAuthProvider | SamlAuthProvider | undefined;
options: AuthProviderFactoryOptions,
) => AuthProviderRouteHandlers;
export type AuthResponse<ProviderInfo> = {
providerInfo: ProviderInfo;
@@ -228,8 +127,6 @@ export type AuthResponse<ProviderInfo> = {
backstageIdentity?: BackstageIdentity;
};
export type OAuthResponse = AuthResponse<OAuthProviderInfo>;
export type BackstageIdentity = {
/**
* The backstage user ID.
@@ -242,63 +139,6 @@ export type BackstageIdentity = {
idToken?: string;
};
export type OAuthProviderInfo = {
/**
* 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 OAuthPrivateInfo = {
/**
* 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 WebMessageResponse =
| {
type: 'authorization_response';
response: AuthResponse<unknown>;
}
| {
type: 'authorization_response';
error: Error;
};
export type PassportDoneCallback<Res, Private = never> = (
err?: Error,
response?: Res,
privateInfo?: Private,
) => void;
export type RedirectInfo = {
/**
* URL to redirect to
*/
url: string;
/**
* Status code to use for the redirect
*/
status?: number;
};
/**
* Used to display login information to user, i.e. sidebar popup.
*
@@ -320,35 +160,3 @@ export type ProfileInfo = {
*/
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;
};
export type OAuthState = {
/* A type for the serialized value in the `state` parameter of the OAuth authorization flow
*/
nonce: string;
env: string;
};
export type EnvironmentIdentifierFn = (
req: express.Request,
) => string | undefined;
+17 -5
View File
@@ -17,12 +17,12 @@
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';
import { NotFoundError } from '@backstage/backend-common';
export interface RouterOptions {
logger: Logger;
@@ -36,6 +36,7 @@ export async function createRouter(
const router = Router();
const logger = options.logger.child({ plugin: 'auth' });
const appUrl = options.config.getString('app.baseUrl');
const backendUrl = options.config.getString('backend.baseUrl');
const authUrl = `${backendUrl}/auth`;
@@ -52,8 +53,8 @@ export async function createRouter(
});
router.use(cookieParser());
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
router.use(express.urlencoded({ extended: false }));
router.use(express.json());
const providersConfig = options.config.getConfig('auth.providers');
const providers = providersConfig.keys();
@@ -64,14 +65,20 @@ export async function createRouter(
const providerConfig = providersConfig.getConfig(providerId);
const providerRouter = createAuthProviderRouter(
providerId,
{ baseUrl: authUrl },
{ baseUrl: authUrl, appUrl },
providerConfig,
logger,
tokenIssuer,
);
router.use(`/${providerId}`, providerRouter);
} catch (e) {
logger.error(e.message);
if (process.env.NODE_ENV !== 'development') {
throw new Error(
`Failed to initialize ${providerId} auth provider, ${e.message}`,
);
}
logger.warn(`Skipping ${providerId} auth provider, ${e.message}`);
}
}
@@ -82,5 +89,10 @@ export async function createRouter(
}),
);
router.use('/:provider/', req => {
const { provider } = req.params;
throw new NotFoundError(`No auth provider registered for '${provider}'`);
});
return router;
}
+2 -2
View File
@@ -30,5 +30,5 @@ This will launch the full example backend and populate its catalog with some moc
## Links
- (Frontend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/catalog]
- (The Backstage homepage)[https://backstage.io]
- [Frontend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/catalog)
- [The Backstage homepage](https://backstage.io)
@@ -0,0 +1,42 @@
/*
* 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) {
// Adds a single 'bootstrap' location that can be used to trigger work in processors.
// This is primarily here to fulfill foreign key constraints.
await knex('locations').insert({
id: require('uuid').v4(),
type: 'bootstrap',
target: 'bootstrap',
});
};
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
await knex('locations')
.where({
type: 'bootstrap',
target: 'bootstrap',
})
.del();
};
+8 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend",
"version": "0.1.1-alpha.18",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -18,11 +18,13 @@
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean",
"mock-data": "./scripts/mock-data.sh"
"mock-data": "./scripts/mock-data.sh",
"mock-data:local": "./scripts/mock-data-local.sh"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.18",
"@backstage/catalog-model": "^0.1.1-alpha.18",
"@backstage/backend-common": "^0.1.1-alpha.21",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/config": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
@@ -39,14 +41,14 @@
"yup": "^0.29.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.18",
"@backstage/cli": "^0.1.1-alpha.21",
"@types/lodash": "^4.14.151",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
"@types/uuid": "^8.0.0",
"@types/yup": "^0.28.2",
"jest-fetch-mock": "^3.0.3",
"msw": "^0.19.5",
"msw": "^0.20.5",
"supertest": "^4.0.2"
},
"files": [
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
for FILE in \
../../packages/catalog-model/examples/*.yaml \
; do \
curl \
--location \
--request POST 'localhost:7000/catalog/locations' \
--header 'Content-Type: application/json' \
--data-raw "{\"type\": \"file\", \"target\": \"../catalog-model/${FILE}\"}"
echo
done
@@ -9,6 +9,8 @@ for URL in \
'playback-lib-component.yaml' \
'www-artist-component.yaml' \
'shuffle-api-component.yaml' \
'petstore-api.yaml' \
'streetlights-api.yaml' \
; do \
curl \
--location \
@@ -17,6 +17,12 @@
import { DatabaseManager } from '../database';
import { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog';
const bootstrapLocation = {
id: expect.any(String),
type: 'bootstrap',
target: 'bootstrap',
};
describe('DatabaseLocationsCatalog', () => {
let catalog: DatabaseLocationsCatalog;
@@ -35,9 +41,12 @@ describe('DatabaseLocationsCatalog', () => {
await expect(
catalog.location('dd12620d-0436-422f-93bd-929aa0788123'),
).resolves.toEqual(expect.objectContaining({ data: location }));
await expect(catalog.locations()).resolves.toEqual([
expect.objectContaining({ data: location }),
]);
await expect(catalog.locations()).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ data: location }),
expect.objectContaining({ data: bootstrapLocation }),
]),
);
});
it('does not return duplicates of rows because of logs', async () => {
@@ -60,11 +69,12 @@ describe('DatabaseLocationsCatalog', () => {
catalog.logUpdateSuccess(location1.id),
).resolves.toBeUndefined();
const locations = await catalog.locations();
expect(locations.length).toBe(2);
expect(locations.length).toBe(3);
expect(locations).toEqual(
expect.arrayContaining([
expect.objectContaining({ data: location1 }),
expect.objectContaining({ data: location2 }),
expect.objectContaining({ data: bootstrapLocation }),
]),
);
});
@@ -24,6 +24,15 @@ import type {
DbLocationsRowWithStatus,
} from './types';
const bootstrapLocation = {
id: expect.any(String),
type: 'bootstrap',
target: 'bootstrap',
message: null,
status: null,
timestamp: null,
};
describe('CommonDatabase', () => {
let db: Database;
let entityRequest: DbEntityRequest;
@@ -85,8 +94,12 @@ describe('CommonDatabase', () => {
await db.addLocation(input);
const locations = await db.locations();
expect(locations).toEqual([output]);
const location = await db.location(locations[0].id);
expect(locations).toEqual(
expect.arrayContaining([output, bootstrapLocation]),
);
const location = await db.location(
locations.find(l => l.type !== 'bootstrap')!.id,
);
expect(location).toEqual(output);
// If we add 2 new update log events,
@@ -105,20 +118,21 @@ describe('CommonDatabase', () => {
DatabaseLocationUpdateLogStatus.FAIL,
);
expect(await db.locations()).toEqual([
{
...output,
status: DatabaseLocationUpdateLogStatus.FAIL,
timestamp: expect.any(String),
},
]);
await db.transaction(tx => db.removeLocation(tx, locations[0].id));
await expect(db.locations()).resolves.toEqual([]);
await expect(db.location(locations[0].id)).rejects.toThrow(
/Found no location/,
await expect(db.locations()).resolves.toEqual(
expect.arrayContaining([
bootstrapLocation,
{
...output,
status: DatabaseLocationUpdateLogStatus.FAIL,
timestamp: expect.any(String),
},
]),
);
await db.transaction(tx => db.removeLocation(tx, location.id));
await expect(db.locations()).resolves.toEqual([bootstrapLocation]);
await expect(db.location(location.id)).rejects.toThrow(/Found no location/);
});
describe('addEntity', () => {
@@ -14,17 +14,16 @@
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common';
import { makeValidator } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { Logger } from 'winston';
import { CommonDatabase } from './CommonDatabase';
import { Database } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-catalog-backend/package.json'),
'../migrations',
const migrationsDir = resolvePackagePath(
'@backstage/plugin-catalog-backend',
'migrations',
);
export type CreateDatabaseOptions = {
@@ -0,0 +1,217 @@
/*
* 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 { LocationSpec, Entity } from '@backstage/catalog-model';
import { CatalogRulesEnforcer } from './CatalogRules';
import { ConfigReader } from '@backstage/config';
const entity = {
user: {
kind: 'User',
} as Entity,
group: {
kind: 'Group',
} as Entity,
component: {
kind: 'component',
} as Entity,
location: {
kind: 'Location',
} as Entity,
};
const location: Record<string, LocationSpec> = {
x: {
type: 'github',
target: 'https://github.com/a/b/blob/master/x.yaml',
},
y: {
type: 'github',
target: 'https://github.com/a/b/blob/master/y.yaml',
},
z: {
type: 'file',
target: '/root/z.yaml',
},
};
describe('CatalogRulesEnforcer', () => {
it('should deny by default', () => {
const enforcer = new CatalogRulesEnforcer([]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should deny all', () => {
const enforcer = new CatalogRulesEnforcer([{ allow: [] }]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should allow all', () => {
const enforcer = new CatalogRulesEnforcer([
{
allow: ['User', 'Group', 'Component', 'Location'].map(kind => ({
kind,
})),
},
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(true);
});
it('should deny groups', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'User' }, { kind: 'Component' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
it('should deny groups from github', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'User' }, { kind: 'Component' }] },
{ allow: [{ kind: 'Group' }], locations: [{ type: 'file' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
it('should allow groups from files', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'Group' }], locations: [{ type: 'file' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
});
it('should not be sensitive to kind case', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'group' }] },
{ allow: [{ kind: 'Component' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
describe('fromConfig', () => {
it('should allow components by default', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(new ConfigReader({}));
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(true);
});
it('should deny all', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({ catalog: { rules: [] } }),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should allow all', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: {
rules: [{ allow: ['User', 'Group'] }, { allow: ['Component'] }],
},
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
it('should deny groups', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: { rules: [{ allow: ['User'] }, { allow: ['Component'] }] },
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should allow groups from a specific github location', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: {
rules: [{ allow: ['user'] }],
locations: [
{
type: 'github',
target: 'https://github.com/a/b/blob/master/x.yaml',
rules: [
{
allow: ['Group'],
},
],
},
],
},
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should not care about location configuration in catalog.rules', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: {
rules: [{ allow: ['Group'], locations: [{ type: 'github' }] }],
},
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
});
});
@@ -0,0 +1,177 @@
/*
* 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 { Config } from '@backstage/config';
import { LocationSpec, Entity } from '@backstage/catalog-model';
/**
* A structure for matching entities to a given rule.
*/
type EntityMatcher = {
kind: string;
};
/**
* A structure for matching locations to a given rule.
*/
type LocationMatcher = {
target?: string;
type: string;
};
/**
* Rules to apply to catalog entities
*
* An undefined list of matchers means match all, an empty list of matchers means match none
*/
type CatalogRule = {
allow: EntityMatcher[];
locations?: LocationMatcher[];
};
export class CatalogRulesEnforcer {
/**
* Default rules used by the catalog.
*
* Denies any location from specifying user or group entities.
*/
static readonly defaultRules: CatalogRule[] = [
{
allow: ['Component', 'API', 'Location'].map(kind => ({ kind })),
},
];
/**
* Loads catalog rules from config.
*
* This reads `catalog.rules` and defaults to the default rules if no value is present.
* The value of the config should be a list of config objects, each with a single `allow`
* field which in turn is a list of entity kinds to allow.
*
* If there is no matching rule to allow an ingested entity, it will be rejected by the catalog.
*
* It also reads in rules from `catalog.locations`, where each location can have a list
* of rules for that specific location, specified in a `rules` field.
*
* For example:
*
* ```yaml
* catalog:
* rules:
* - allow: [Component, API]
*
* locations:
* - type: github
* target: https://github.com/org/repo/blob/master/users.yaml
* rules:
* - allow: [User, Group]
* - type: github
* target: https://github.com/org/repo/blob/master/systems.yaml
* rules:
* - allow: [System]
* ```
*/
static fromConfig(config: Config) {
const rules = new Array<CatalogRule>();
if (config.has('catalog.rules')) {
const globalRules = config.getConfigArray('catalog.rules').map(sub => ({
allow: sub.getStringArray('allow').map(kind => ({ kind })),
}));
rules.push(...globalRules);
} else {
rules.push(...CatalogRulesEnforcer.defaultRules);
}
if (config.has('catalog.locations')) {
const locationRules = config
.getConfigArray('catalog.locations')
.flatMap(locConf => {
if (!locConf.has('rules')) {
return [];
}
const type = locConf.getString('type');
const target = locConf.getString('target');
return locConf.getConfigArray('rules').map(ruleConf => ({
allow: ruleConf.getStringArray('allow').map(kind => ({ kind })),
locations: [{ type, target }],
}));
});
rules.push(...locationRules);
}
return new CatalogRulesEnforcer(rules);
}
constructor(private readonly rules: CatalogRule[]) {}
/**
* Checks wether a specific entity/location combination is allowed
* according to the configured rules.
*/
isAllowed(entity: Entity, location: LocationSpec) {
for (const rule of this.rules) {
if (!this.matchLocation(location, rule.locations)) {
continue;
}
if (this.matchEntity(entity, rule.allow)) {
return true;
}
}
return false;
}
private matchLocation(
location: LocationSpec,
matchers?: LocationMatcher[],
): boolean {
if (!matchers) {
return true;
}
for (const matcher of matchers) {
if (matcher.type !== location.type) {
continue;
}
if (matcher.target && matcher.target !== location.target) {
continue;
}
return true;
}
return false;
}
private matchEntity(entity: Entity, matchers?: EntityMatcher[]): boolean {
if (!matchers) {
return true;
}
for (const matcher of matchers) {
if (entity.kind.toLowerCase() !== matcher.kind.toLowerCase()) {
continue;
}
return true;
}
return false;
}
}
@@ -15,6 +15,7 @@
*/
import { getVoidLogger } from '@backstage/backend-common';
import { Config, ConfigReader } from '@backstage/config';
import {
Entity,
EntityPolicies,
@@ -29,8 +30,11 @@ import { GithubReaderProcessor } from './processors/GithubReaderProcessor';
import { GithubApiReaderProcessor } from './processors/GithubApiReaderProcessor';
import { GitlabApiReaderProcessor } from './processors/GitlabApiReaderProcessor';
import { GitlabReaderProcessor } from './processors/GitlabReaderProcessor';
import { BitbucketApiReaderProcessor } from './processors/BitbucketApiReaderProcessor';
import { AzureApiReaderProcessor } from './processors/AzureApiReaderProcessor';
import { UrlReaderProcessor } from './processors/UrlReaderProcessor';
import { LocationRefProcessor } from './processors/LocationEntityProcessor';
import { StaticLocationProcessor } from './processors/StaticLocationProcessor';
import * as result from './processors/results';
import {
LocationProcessor,
@@ -43,26 +47,42 @@ import {
} from './processors/types';
import { YamlProcessor } from './processors/YamlProcessor';
import { LocationReader, ReadLocationResult } from './types';
import { CatalogRulesEnforcer } from './CatalogRules';
// The max amount of nesting depth of generated work items
const MAX_DEPTH = 10;
type Options = {
logger?: Logger;
config?: Config;
processors?: LocationProcessor[];
};
/**
* Implements the reading of a location through a series of processor tasks.
*/
export class LocationReaders implements LocationReader {
private readonly logger: Logger;
private readonly processors: LocationProcessor[];
private readonly rulesEnforcer: CatalogRulesEnforcer;
static defaultProcessors(
entityPolicy: EntityPolicy = new EntityPolicies(),
): LocationProcessor[] {
static defaultProcessors(options: {
config?: Config;
entityPolicy?: EntityPolicy;
}): LocationProcessor[] {
const {
config = new ConfigReader({}, 'missing-config'),
entityPolicy = new EntityPolicies(),
} = options;
return [
StaticLocationProcessor.fromConfig(config),
new FileReaderProcessor(),
new GithubReaderProcessor(),
new GithubApiReaderProcessor(),
new GitlabApiReaderProcessor(),
new GithubApiReaderProcessor(config),
new GitlabApiReaderProcessor(config),
new GitlabReaderProcessor(),
new BitbucketApiReaderProcessor(config),
new AzureApiReaderProcessor(config),
new UrlReaderProcessor(),
new YamlProcessor(),
new EntityPolicyProcessor(entityPolicy),
@@ -71,12 +91,16 @@ export class LocationReaders implements LocationReader {
];
}
constructor(
logger: Logger = getVoidLogger(),
processors: LocationProcessor[] = LocationReaders.defaultProcessors(),
) {
constructor({
logger = getVoidLogger(),
config,
processors = LocationReaders.defaultProcessors({ config }),
}: Options) {
this.logger = logger;
this.processors = processors;
this.rulesEnforcer = config
? CatalogRulesEnforcer.fromConfig(config)
: new CatalogRulesEnforcer(CatalogRulesEnforcer.defaultRules);
}
async read(location: LocationSpec): Promise<ReadLocationResult> {
@@ -93,11 +117,20 @@ export class LocationReaders implements LocationReader {
} else if (item.type === 'data') {
await this.handleData(item, emit);
} else if (item.type === 'entity') {
const entity = await this.handleEntity(item, emit);
output.entities.push({
entity,
location: item.location,
});
if (this.rulesEnforcer.isAllowed(item.entity, item.location)) {
const entity = await this.handleEntity(item, emit);
output.entities.push({
entity,
location: item.location,
});
} else {
output.errors.push({
location: item.location,
error: new Error(
`Entity of kind ${item.entity.kind} is not allowed from location ${item.location.target}:${item.location.type}`,
),
});
}
} else if (item.type === 'error') {
await this.handleError(item, emit);
output.errors.push({
@@ -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 { AzureApiReaderProcessor } from './AzureApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('AzureApiReaderProcessor', () => {
const createConfig = (token: string | undefined) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
azureApi: {
privateToken: token,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new AzureApiReaderProcessor(createConfig(undefined));
const tests = [
{
target:
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster',
url: new URL(
'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master',
),
err: undefined,
},
{
target:
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml',
url: new URL(
'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml',
),
err: undefined,
},
{
target: 'https://api.com/a/b/blob/master/path/to/c.yaml',
url: null,
err:
'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path',
},
{
target: 'com/a/b/blob/master/path/to/c.yaml',
url: null,
err:
'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
},
];
for (const test of tests) {
if (test.err) {
expect(() => processor.buildRawUrl(test.target)).toThrowError(test.err);
} else if (test.url) {
expect(processor.buildRawUrl(test.target).toString()).toEqual(
test.url.toString(),
);
} else {
throw new Error(
'This should not have happened. Either err or url should have matched.',
);
}
}
});
it('should return request options', () => {
const tests = [
{
token: '0123456789',
expect: {
headers: {
Authorization: 'Basic OjAxMjM0NTY3ODk=',
},
},
},
{
token: '',
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.azureApi.privateToken' in '', got empty-string, wanted string",
},
{
token: undefined,
expect: {
headers: {},
},
},
];
for (const test of tests) {
if (test.err) {
expect(
() => new AzureApiReaderProcessor(createConfig(test.token)),
).toThrowError(test.err);
} else {
const processor = new AzureApiReaderProcessor(createConfig(test.token));
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -0,0 +1,143 @@
/*
* 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 { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class AzureApiReaderProcessor implements LocationProcessor {
private privateToken: string;
constructor(config: Config) {
this.privateToken =
config.getOptionalString('catalog.processors.azureApi.privateToken') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = {};
if (this.privateToken !== '') {
headers.Authorization = `Basic ${Buffer.from(
`:${this.privateToken}`,
'utf8',
).toString('base64')}`;
}
const requestOptions: RequestInit = {
headers,
};
return requestOptions;
}
async readLocation(
location: LocationSpec,
optional: boolean,
emit: LocationProcessorEmit,
): Promise<boolean> {
if (location.type !== 'azure/api') {
return false;
}
try {
const url = this.buildRawUrl(location.target);
const response = await fetch(url.toString(), this.getRequestOptions());
// for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html
if (response.ok && response.status !== 203) {
const data = await response.buffer();
emit(result.data(location, data));
} else {
const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
if (!optional) {
emit(result.notFoundError(location, message));
}
} else {
emit(result.generalError(location, message));
}
}
} catch (e) {
const message = `Unable to read ${location.type} ${location.target}, ${e}`;
emit(result.generalError(location, message));
}
return true;
}
// Converts
// from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents
// to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch}
buildRawUrl(target: string): URL {
try {
const url = new URL(target);
const [
empty,
userOrOrg,
project,
srcKeyword,
repoName,
] = url.pathname.split('/');
const path = url.searchParams.get('path') || '';
const ref = url.searchParams.get('version')?.substr(2);
if (
url.hostname !== 'dev.azure.com' ||
empty !== '' ||
userOrOrg === '' ||
project === '' ||
srcKeyword !== '_git' ||
repoName === '' ||
path === '' ||
ref === '' ||
!path.match(/\.yaml$/)
) {
throw new Error('Wrong Azure Devops URL or Invalid file path');
}
// transform to api
url.pathname = [
empty,
userOrOrg,
project,
'_apis',
'git',
'repositories',
repoName,
'items',
].join('/');
const queryParams = [`path=${path}`];
if (ref) {
queryParams.push(`version=${ref}`);
}
url.search = queryParams.join('&');
url.protocol = 'https';
return url;
} catch (e) {
throw new Error(`Incorrect url: ${target}, ${e}`);
}
}
}
@@ -0,0 +1,161 @@
/*
* 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 { BitbucketApiReaderProcessor } from './BitbucketApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('BitbucketApiReaderProcessor', () => {
const createConfig = (
username: string | undefined,
appPassword: string | undefined,
) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
bitbucketApi: {
username: username,
appPassword: appPassword,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new BitbucketApiReaderProcessor(
createConfig(undefined, undefined),
);
const tests = [
{
target:
'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml',
url: new URL(
'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml',
),
err: undefined,
},
{
target: 'https://api.com/a/b/blob/master/path/to/c.yaml',
url: null,
err:
'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Bitbucket URL or Invalid file path',
},
{
target: 'com/a/b/blob/master/path/to/c.yaml',
url: null,
err:
'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
},
];
for (const test of tests) {
if (test.err) {
expect(() => processor.buildRawUrl(test.target)).toThrowError(test.err);
} else if (test.url) {
expect(processor.buildRawUrl(test.target).toString()).toEqual(
test.url.toString(),
);
} else {
throw new Error(
'This should not have happened. Either err or url should have matched.',
);
}
}
});
it('should return request options', () => {
const tests = [
{
username: '',
password: '',
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.bitbucketApi.username' in '', got empty-string, wanted string",
},
{
username: 'only-user-provided',
password: '',
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.bitbucketApi.appPassword' in '', got empty-string, wanted string",
},
{
username: '',
password: 'only-password-provided',
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.bitbucketApi.username' in '', got empty-string, wanted string",
},
{
username: 'some-user',
password: 'my-secret',
expect: {
headers: {
Authorization: 'Basic c29tZS11c2VyOm15LXNlY3JldA==',
},
},
},
{
username: undefined,
password: undefined,
expect: {
headers: {},
},
},
{
username: 'only-user-provided',
password: undefined,
expect: {
headers: {},
},
},
{
username: undefined,
password: 'only-password-provided',
expect: {
headers: {},
},
},
];
for (const test of tests) {
if (test.err) {
expect(
() =>
new BitbucketApiReaderProcessor(
createConfig(test.username, test.password),
),
).toThrowError(test.err);
} else {
const processor = new BitbucketApiReaderProcessor(
createConfig(test.username, test.password),
);
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -0,0 +1,134 @@
/*
* 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 { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class BitbucketApiReaderProcessor implements LocationProcessor {
private username: string;
private password: string;
constructor(config: Config) {
this.username =
config.getOptionalString('catalog.processors.bitbucketApi.username') ??
'';
this.password =
config.getOptionalString('catalog.processors.bitbucketApi.appPassword') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = {};
if (this.username !== '' && this.password !== '') {
headers.Authorization = `Basic ${Buffer.from(
`${this.username}:${this.password}`,
'utf8',
).toString('base64')}`;
}
const requestOptions: RequestInit = {
headers,
};
return requestOptions;
}
async readLocation(
location: LocationSpec,
optional: boolean,
emit: LocationProcessorEmit,
): Promise<boolean> {
if (location.type !== 'bitbucket/api') {
return false;
}
try {
const url = this.buildRawUrl(location.target);
const response = await fetch(url.toString(), this.getRequestOptions());
if (response.ok) {
const data = await response.buffer();
emit(result.data(location, data));
} else {
const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`;
if (response.status === 404) {
if (!optional) {
emit(result.notFoundError(location, message));
}
} else {
emit(result.generalError(location, message));
}
}
} catch (e) {
const message = `Unable to read ${location.type} ${location.target}, ${e}`;
emit(result.generalError(location, message));
}
return true;
}
// Converts
// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml
// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml
buildRawUrl(target: string): URL {
try {
const url = new URL(target);
const [
empty,
userOrOrg,
repoName,
srcKeyword,
ref,
...restOfPath
] = url.pathname.split('/');
if (
url.hostname !== 'bitbucket.org' ||
empty !== '' ||
userOrOrg === '' ||
repoName === '' ||
srcKeyword !== 'src' ||
!restOfPath.join('/').match(/\.yaml$/)
) {
throw new Error('Wrong Bitbucket URL or Invalid file path');
}
// transform to api
url.pathname = [
empty,
'2.0',
'repositories',
userOrOrg,
repoName,
'src',
ref,
...restOfPath,
].join('/');
url.hostname = 'api.bitbucket.org';
url.protocol = 'https';
return url;
} catch (e) {
throw new Error(`Incorrect url: ${target}, ${e}`);
}
}
}
@@ -15,10 +15,27 @@
*/
import { GithubApiReaderProcessor } from './GithubApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('GithubApiReaderProcessor', () => {
const createConfig = (token: string | undefined) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
githubApi: {
privateToken: token,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new GithubApiReaderProcessor();
const processor = new GithubApiReaderProcessor(createConfig(undefined));
const tests = [
{
@@ -53,8 +70,14 @@ describe('GithubApiReaderProcessor', () => {
for (const test of tests) {
if (test.err) {
expect(() => processor.buildRawUrl(test.target)).toThrowError(test.err);
} else if (test.url) {
expect(processor.buildRawUrl(test.target).toString()).toEqual(
test.url.toString(),
);
} else {
expect(processor.buildRawUrl(test.target)).toEqual(test.url);
throw new Error(
'This should not have happened. Either err or url should have matched.',
);
}
}
});
@@ -72,6 +95,16 @@ describe('GithubApiReaderProcessor', () => {
},
{
token: '',
err:
"Invalid type in config for key 'catalog.processors.githubApi.privateToken' in '', got empty-string, wanted string",
expect: {
headers: {
Accept: 'application/vnd.github.v3.raw',
},
},
},
{
token: undefined,
expect: {
headers: {
Accept: 'application/vnd.github.v3.raw',
@@ -81,9 +114,16 @@ describe('GithubApiReaderProcessor', () => {
];
for (const test of tests) {
process.env.GITHUB_PRIVATE_TOKEN = test.token;
const processor = new GithubApiReaderProcessor();
expect(processor.getRequestOptions()).toEqual(test.expect);
if (test.err) {
expect(
() => new GithubApiReaderProcessor(createConfig(test.token)),
).toThrowError(test.err);
} else {
const processor = new GithubApiReaderProcessor(
createConfig(test.token),
);
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -18,9 +18,16 @@ import { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class GithubApiReaderProcessor implements LocationProcessor {
private privateToken: string = process.env.GITHUB_PRIVATE_TOKEN || '';
private privateToken: string;
constructor(config: Config) {
this.privateToken =
config.getOptionalString('catalog.processors.githubApi.privateToken') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = {
@@ -15,11 +15,35 @@
*/
import { LocationSpec } from '@backstage/catalog-model';
import fetch from 'node-fetch';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class GithubReaderProcessor implements LocationProcessor {
private privateToken: string;
constructor(config?: Config) {
this.privateToken =
config?.getOptionalString('catalog.processors.github.privateToken') ?? '';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = {
Accept: 'application/vnd.github.v3.raw',
};
if (this.privateToken !== '') {
headers.Authorization = `token ${this.privateToken}`;
}
const requestOptions: RequestInit = {
headers,
};
return requestOptions;
}
async readLocation(
location: LocationSpec,
optional: boolean,
@@ -34,7 +58,7 @@ export class GithubReaderProcessor implements LocationProcessor {
// TODO(freben): Should "hard" errors thrown by this line be treated as
// notFound instead of fatal?
const response = await fetch(url.toString());
const response = await fetch(url.toString(), this.getRequestOptions());
if (response.ok) {
const data = await response.buffer();
@@ -15,17 +15,34 @@
*/
import { GitlabApiReaderProcessor } from './GitlabApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('GitlabApiReaderProcessor', () => {
const createConfig = (token: string | undefined) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
gitlabApi: {
privateToken: token,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new GitlabApiReaderProcessor();
const processor = new GitlabApiReaderProcessor(createConfig(undefined));
const tests = [
{
target:
'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml',
url: new URL(
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch',
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
),
err: undefined,
},
@@ -33,7 +50,7 @@ describe('GitlabApiReaderProcessor', () => {
target:
'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml',
url: new URL(
'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch',
'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
),
err: undefined,
},
@@ -41,7 +58,7 @@ describe('GitlabApiReaderProcessor', () => {
target:
'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup
url: new URL(
'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch',
'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch',
),
err: undefined,
},
@@ -50,7 +67,7 @@ describe('GitlabApiReaderProcessor', () => {
'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/',
url: null,
err:
'Incorrect url: https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/, Error: Gitlab url does not end in .ya?ml',
'Incorrect url: https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/, Error: GitLab url does not end in .ya?ml',
},
];
@@ -59,8 +76,14 @@ describe('GitlabApiReaderProcessor', () => {
expect(() => processor.buildRawUrl(test.target, 12345)).toThrowError(
test.err,
);
} else if (test.url) {
expect(processor.buildRawUrl(test.target, 12345).toString()).toEqual(
test.url.toString(),
);
} else {
expect(processor.buildRawUrl(test.target, 12345)).toEqual(test.url);
throw new Error(
'This should not have happened. Either err or url should have matched.',
);
}
}
});
@@ -77,6 +100,16 @@ describe('GitlabApiReaderProcessor', () => {
},
{
token: '',
err:
"Invalid type in config for key 'catalog.processors.gitlabApi.privateToken' in '', got empty-string, wanted string",
expect: {
headers: {
'PRIVATE-TOKEN': '',
},
},
},
{
token: undefined,
expect: {
headers: {
'PRIVATE-TOKEN': '',
@@ -86,9 +119,16 @@ describe('GitlabApiReaderProcessor', () => {
];
for (const test of tests) {
process.env.GITLAB_PRIVATE_TOKEN = test.token;
const processor = new GitlabApiReaderProcessor();
expect(processor.getRequestOptions()).toEqual(test.expect);
if (test.err) {
expect(
() => new GitlabApiReaderProcessor(createConfig(test.token)),
).toThrowError(test.err);
} else {
const processor = new GitlabApiReaderProcessor(
createConfig(test.token),
);
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -18,9 +18,16 @@ import { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class GitlabApiReaderProcessor implements LocationProcessor {
private privateToken: string = process.env.GITLAB_PRIVATE_TOKEN || '';
private privateToken: string;
constructor(config: Config) {
this.privateToken =
config.getOptionalString('catalog.processors.gitlabApi.privateToken') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = { 'PRIVATE-TOKEN': '' };
@@ -77,7 +84,7 @@ export class GitlabApiReaderProcessor implements LocationProcessor {
const branchAndfilePath = url.pathname.split('/-/blob/')[1];
if (!branchAndfilePath.match(/\.ya?ml$/)) {
throw new Error('Gitlab url does not end in .ya?ml');
throw new Error('GitLab url does not end in .ya?ml');
}
const [branch, ...filePath] = branchAndfilePath.split('/');
@@ -127,7 +134,7 @@ export class GitlabApiReaderProcessor implements LocationProcessor {
return projectID;
} catch (e) {
throw new Error(`Could not get Gitlab ProjectID for: ${target}, ${e}`);
throw new Error(`Could not get GitLab ProjectID for: ${target}, ${e}`);
}
}
}
@@ -77,7 +77,7 @@ export class GitlabReaderProcessor implements LocationProcessor {
blobKeyword !== 'blob' ||
!restOfPath.join('/').match(/\.yaml$/)
) {
throw new Error('Wrong Gitlab URL');
throw new Error('Wrong GitLab URL');
}
// Replace 'blob' with 'raw'
@@ -0,0 +1,53 @@
/*
* 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 { LocationSpec } from '@backstage/catalog-model';
import * as result from './results';
import { Config } from '@backstage/config';
import { LocationProcessorEmit } from './types';
export class StaticLocationProcessor implements StaticLocationProcessor {
static fromConfig(config: Config): StaticLocationProcessor {
const locations: LocationSpec[] = [];
const lConfigs = config.getOptionalConfigArray('catalog.locations') ?? [];
for (const lConfig of lConfigs) {
const type = lConfig.getString('type');
const target = lConfig.getString('target');
locations.push({ type, target });
}
return new StaticLocationProcessor(locations);
}
constructor(private readonly staticLocations: LocationSpec[]) {}
async readLocation(
location: LocationSpec,
_optional: boolean,
emit: LocationProcessorEmit,
): Promise<boolean> {
if (location.type !== 'bootstrap') {
return false;
}
for (const staticLocation of this.staticLocations) {
emit(result.location(staticLocation, false));
}
return true;
}
}
@@ -0,0 +1,151 @@
/*
* 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 { YamlProcessor } from './YamlProcessor';
import { Entity } from '@backstage/catalog-model';
import yaml from 'yaml';
import { TextEncoder } from 'util';
import {
LocationProcessorEntityResult,
LocationProcessorErrorResult,
} from './types';
describe('YamlProcessor', () => {
const processor = new YamlProcessor();
const locationSpec = {
type: 'url',
target: 'http://example.com/component.yaml',
};
function encodeEntity(entity: string): Buffer {
const data = new TextEncoder().encode(entity);
return Buffer.from(data);
}
it('should only process files with yaml', async () => {
const wrongLocationSpec = {
type: 'url',
target: 'http://example.com/component.json',
};
const buffer = Buffer.from([]);
const never = jest.fn();
expect(await processor.parseData(buffer, wrongLocationSpec, never)).toBe(
false,
);
expect(never).not.toBeCalled();
});
it('should process url that contains yaml', async () => {
const containsYamlLocationSpec = {
type: 'url',
target: 'http://example.com/component?path=test.yaml&c=1&d=2',
};
const buffer = Buffer.from([]);
const emit = jest.fn();
expect(
await processor.parseData(buffer, containsYamlLocationSpec, emit),
).toBe(true);
expect(emit).toBeCalled();
});
it('should process entity with yaml', async () => {
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
spec: {},
} as Entity;
const buffer = encodeEntity(yaml.stringify(entity));
const emit = jest.fn();
expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true);
const e = emit.mock.calls[0][0] as LocationProcessorEntityResult;
expect(e.type).toBe('entity');
expect(e.location).toBe(locationSpec);
expect(e.entity).toEqual(entity);
});
it('should process multiple entities with yaml', async () => {
const entityComponent = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
spec: {},
} as Entity;
const entityApi = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'my-api',
},
spec: {},
} as Entity;
const buffer = encodeEntity(
`${yaml.stringify(entityComponent)}---\n${yaml.stringify(entityApi)}`,
);
const emit = jest.fn();
expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true);
const eComponent = emit.mock.calls[0][0] as LocationProcessorEntityResult;
expect(eComponent.type).toBe('entity');
expect(eComponent.location).toBe(locationSpec);
expect(eComponent.entity).toEqual(entityComponent);
const eApi = emit.mock.calls[1][0] as LocationProcessorEntityResult;
expect(eApi.type).toBe('entity');
expect(eApi.location).toBe(locationSpec);
expect(eApi.entity).toEqual(entityApi);
});
it('should fail process entity on invalid yaml', async () => {
const buffer = encodeEntity('{');
const emit = jest.fn();
expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true);
const e = emit.mock.calls[0][0] as LocationProcessorErrorResult;
expect(e.error.message).toMatch(/^YAML error, /);
expect(e.type).toBe('error');
expect(e.location).toBe(locationSpec);
});
it('should fail process entity if not object at root', async () => {
const buffer = encodeEntity('[]');
const emit = jest.fn();
expect(await processor.parseData(buffer, locationSpec, emit)).toBe(true);
const e = emit.mock.calls[0][0] as LocationProcessorErrorResult;
expect(e.error.message).toMatch(/^Expected object at root, got /);
expect(e.type).toBe('error');
expect(e.location).toBe(locationSpec);
});
});
@@ -26,7 +26,7 @@ export class YamlProcessor implements LocationProcessor {
location: LocationSpec,
emit: LocationProcessorEmit,
): Promise<boolean> {
if (!location.target.match(/\.ya?ml$/)) {
if (!location.target.match(/\.ya?ml/)) {
return false;
}
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { createServiceBuilder } from '@backstage/backend-common';
import {
createServiceBuilder,
loadBackendConfig,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { Server } from 'http';
import { Logger } from 'winston';
import { HigherOrderOperations } from '..';
@@ -34,12 +38,13 @@ export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'catalog-backend' });
const config = ConfigReader.fromConfigs(await loadBackendConfig());
logger.debug('Creating application...');
const db = await DatabaseManager.createInMemoryDatabase({ logger });
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const locationReader = new LocationReaders();
const locationReader = new LocationReaders({ logger, config });
const higherOrderOperation = new HigherOrderOperations(
entitiesCatalog,
locationsCatalog,
+2 -2
View File
@@ -9,5 +9,5 @@ supply the base views to show and manage them.
## Links
- (Backend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/catalog-backend]
- (The Backstage homepage)[https://backstage.io]
- [Backend part of the plugin](https://github.com/spotify/backstage/tree/master/plugins/catalog-backend)
- [The Backstage homepage](https://backstage.io)
+17 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog",
"version": "0.1.1-alpha.18",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,27 +21,31 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.18",
"@backstage/core": "^0.1.1-alpha.18",
"@backstage/plugin-github-actions": "^0.1.1-alpha.18",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.18",
"@backstage/plugin-sentry": "^0.1.1-alpha.18",
"@backstage/theme": "^0.1.1-alpha.18",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/plugin-api-docs": "^0.1.1-alpha.21",
"@backstage/plugin-github-actions": "^0.1.1-alpha.21",
"@backstage/plugin-jenkins": "^0.1.1-alpha.21",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.21",
"@backstage/plugin-techdocs": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"moment": "^2.26.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-helmet": "6.1.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"swr": "^0.2.2"
"swr": "^0.3.0",
"@types/react": "^16.9"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.18",
"@backstage/dev-utils": "^0.1.1-alpha.18",
"@backstage/test-utils": "^0.1.1-alpha.18",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@backstage/test-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/react-hooks": "^3.3.0",
@@ -49,9 +53,9 @@
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3",
"msw": "^0.19.0",
"msw": "^0.20.5",
"react-test-renderer": "^16.13.1",
"whatwg-fetch": "^2.0.0"
"whatwg-fetch": "^3.4.0"
},
"files": [
"dist"
+13 -20
View File
@@ -18,25 +18,21 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogClient } from './CatalogClient';
import { Entity } from '@backstage/catalog-model';
import { UrlPatternDiscovery } from '@backstage/core';
const server = setupServer();
const mockBaseUrl = 'http://backstage:9191/i-am-a-mock-base';
const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl);
describe('CatalogClient', () => {
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const mockApiOrigin = 'http://backstage:9191';
const mockBasePath = '/i-am-a-mock-base';
let client = new CatalogClient({
apiOrigin: mockApiOrigin,
basePath: mockBasePath,
});
let client = new CatalogClient({ discoveryApi });
beforeEach(() => {
client = new CatalogClient({
apiOrigin: mockApiOrigin,
basePath: mockBasePath,
});
client = new CatalogClient({ discoveryApi });
});
describe('getEntiies', () => {
@@ -61,7 +57,7 @@ describe('CatalogClient', () => {
beforeEach(() => {
server.use(
rest.get(`${mockApiOrigin}${mockBasePath}/entities`, (_, res, ctx) => {
rest.get(`${mockBaseUrl}/entities`, (_, res, ctx) => {
return res(ctx.json(defaultResponse));
}),
);
@@ -75,15 +71,12 @@ describe('CatalogClient', () => {
it('builds entity search filters properly', async () => {
expect.assertions(2);
server.use(
rest.get(
`${mockApiOrigin}${mockBasePath}/entities`,
(req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'a=1&b=2&b=3&%C3%B6=%3D',
);
return res(ctx.json([]));
},
),
rest.get(`${mockBaseUrl}/entities`, (req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe(
'a=1&b=2&b=3&%C3%B6=%3D',
);
return res(ctx.json([]));
}),
);
const entities = await client.getEntities({

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