Merge branch 'master' into add-saml-login
This commit is contained in:
+2
-2
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## Creating a plugin
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
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)
|
||||
@@ -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 |
@@ -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} />
|
||||
);
|
||||
}
|
||||
};
|
||||
+13
-11
@@ -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>
|
||||
);
|
||||
};
|
||||
+150
@@ -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} />;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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();
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
+2
-1
@@ -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';
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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 {};
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
+13
-160
@@ -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,
|
||||
});
|
||||
+50
-124
@@ -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';
|
||||
@@ -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>;
|
||||
}
|
||||
+25
-8
@@ -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';
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user