Merge branch 'master' of https://github.com/backstage/backstage into bazaar-workflow-changes

This commit is contained in:
Lykke Axlin
2022-01-11 13:31:06 +01:00
1214 changed files with 23788 additions and 8394 deletions
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+13
View File
@@ -0,0 +1,13 @@
# Airbrake
Welcome to the Airbrake plugin!
This is a plugin providing connectivity between Backstage and Airbrake (https://airbrake.io/).
_This plugin is currently not fit for use as it is work in progress_
## Getting started
You can serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
+27
View File
@@ -0,0 +1,27 @@
## API Report File for "@backstage/plugin-airbrake"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { RouteRef } from '@backstage/core-plugin-api';
// Warning: (ae-missing-release-tag) "airbrakePlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const airbrakePlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{}
>;
// Warning: (ae-missing-release-tag) "EntityAirbrakeContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const EntityAirbrakeContent: () => JSX.Element;
// (No @packageDocumentation comment for this package)
```
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { createDevApp } from '@backstage/dev-utils';
import { EntityAirbrakeContent, airbrakePlugin } from '../src/plugin';
import {
Content,
ContentHeader,
Header,
HeaderLabel,
Page,
SupportButton,
} from '@backstage/core-components';
createDevApp()
.registerPlugin(airbrakePlugin)
.addPage({
element: (
<Page themeId="tool">
<Header
title="Airbrake demo application"
subtitle="Test the plugin below"
>
<HeaderLabel label="Owner" value="Owner" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Airbrake">
<SupportButton>
A description of your plugin goes here.
</SupportButton>
</ContentHeader>
<EntityAirbrakeContent />
</Content>
</Page>
),
title: 'Root Page',
path: '/airbrake',
})
.render();
+63
View File
@@ -0,0 +1,63 @@
{
"name": "@backstage/plugin-airbrake",
"version": "0.0.0",
"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/core-components": "^0.8.3",
"@backstage/core-plugin-api": "^0.4.1",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"react-use": "^17.2.4",
"object-hash": "^2.2.0"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@types/object-hash": "^2.2.1",
"@backstage/app-defaults": "^0.1.2",
"@backstage/cli": "^0.10.5",
"@backstage/core-app-api": "^0.3.1",
"@backstage/dev-utils": "^0.2.16",
"@backstage/test-utils": "^0.2.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"msw": "^0.35.0",
"cross-fetch": "^3.0.6",
"react-router": "6.0.0-beta.0"
},
"files": [
"dist"
],
"jest": {
"coverageThreshold": {
"global": {
"functions": 100,
"lines": 100,
"statements": 100
}
}
}
}
@@ -0,0 +1,25 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { EntityAirbrakeContent } from './plugin';
import { renderInTestApp } from '@backstage/test-utils';
describe('The Airbrake entity', () => {
it('should render the content properly', async () => {
const rendered = await renderInTestApp(<EntityAirbrakeContent />);
expect(rendered.getByText('ChunkLoadError')).toBeInTheDocument();
});
});
@@ -0,0 +1,32 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { EntityAirbrakeContent } from './EntityAirbrakeContent';
import exampleData from './example-data.json';
import { renderInTestApp } from '@backstage/test-utils';
describe('EntityAirbrakeContent', () => {
it('renders all errors sent from Airbrake', async () => {
const table = await renderInTestApp(<EntityAirbrakeContent />);
expect(exampleData.groups.length).toBeGreaterThan(0);
for (const group of exampleData.groups) {
expect(
await table.getByText(group.errors[0].message),
).toBeInTheDocument();
}
});
});
@@ -0,0 +1,48 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { Grid, Typography } from '@material-ui/core';
import { InfoCard } from '@backstage/core-components';
import exampleData from './example-data.json';
import hash from 'object-hash';
import { makeStyles } from '@material-ui/core/styles';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles<BackstageTheme>(() => ({
multilineText: {
whiteSpace: 'pre-wrap',
},
}));
export const EntityAirbrakeContent = () => {
const classes = useStyles();
return (
<Grid container spacing={3} direction="column">
{exampleData.groups.map(group => (
<Grid item key={group.id}>
{group.errors.map(error => (
<InfoCard title={error.type} key={hash(error)}>
<Typography variant="body1" className={classes.multilineText}>
{error.message}
</Typography>
</InfoCard>
))}
</Grid>
))}
</Grid>
);
};
@@ -0,0 +1,85 @@
{
"count": 2,
"end": "",
"groups": [
{
"id": "1",
"projectId": 123,
"resolved": false,
"muted": false,
"mutedBy": 0,
"mutedAt": null,
"errors": [
{
"type": "Error",
"message": "useSearch must be used within a SearchContextProvider",
"backtrace": [
{
"file": "webpack-internal:///../../node_modules/@backstage/plugin-search/dist/esm/index-893ec2f5.esm.js",
"function": "useSearch",
"line": 303,
"column": 11,
"code": null
}
]
}
],
"attributes": null,
"context": {
"action": "",
"component": "",
"environment": "local",
"severity": "error"
},
"lastDeployId": "0",
"lastDeployAt": null,
"lastNoticeId": "234",
"lastNoticeAt": "2021-12-19T09:59:00.124Z",
"noticeCount": 5,
"noticeTotalCount": 5,
"commentCount": 0,
"createdAt": "2021-12-19T09:44:30.067447Z"
},
{
"id": "2",
"projectId": 123,
"resolved": true,
"muted": false,
"mutedBy": 0,
"mutedAt": null,
"errors": [
{
"type": "ChunkLoadError",
"message": "Loading chunk 7764 failed.",
"backtrace": [
{
"file": "/PROJECT_ROOT/static/runtime.069c874d.js",
"function": "Object._.f.j",
"line": 1,
"column": 19465,
"code": null
}
]
}
],
"attributes": null,
"context": {
"action": "",
"component": "",
"environment": "local",
"severity": "error"
},
"lastDeployId": "0",
"lastDeployAt": null,
"lastNoticeId": "345",
"lastNoticeAt": "2021-12-15T17:16:38.419Z",
"noticeCount": 1,
"noticeTotalCount": 1,
"commentCount": 0,
"createdAt": "2021-12-15T17:16:38.41983Z"
}
],
"page": 1,
"resolvedCount": 1,
"unresolvedCount": 1
}
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { EntityAirbrakeContent, airbrakePlugin } from './index';
describe('The Airbrake plugin index file', () => {
it('exports the plugin and content', async () => {
expect(EntityAirbrakeContent).toBeTruthy();
expect(airbrakePlugin).toBeTruthy();
});
});
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { airbrakePlugin, EntityAirbrakeContent } from './plugin';
@@ -14,15 +14,10 @@
* limitations under the License.
*/
export * from './DefaultResultListItem';
export * from './Filters';
export * from './SearchBar';
export * from './SearchContext';
export * from './SearchFilter';
export * from './SearchModal';
export * from './SearchPage';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchType';
export * from './SidebarSearch';
export * from './SidebarSearchModal';
import { airbrakePlugin } from './plugin';
describe('catalog', () => {
it('should export plugin', () => {
expect(airbrakePlugin).toBeDefined();
});
});
+39
View File
@@ -0,0 +1,39 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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,
createRoutableExtension,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
export const airbrakePlugin = createPlugin({
id: 'airbrake',
routes: {
root: rootRouteRef,
},
});
export const EntityAirbrakeContent = airbrakePlugin.provide(
createRoutableExtension({
name: 'EntityAirbrakeContent',
component: () =>
import('./components/EntityAirbrakeContent/EntityAirbrakeContent').then(
m => m.EntityAirbrakeContent,
),
mountPoint: rootRouteRef,
}),
);
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'airbrake',
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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';
import 'cross-fetch/polyfill';
+19
View File
@@ -1,5 +1,24 @@
# @backstage/plugin-allure
## 0.1.10
### Patch Changes
- 4ce51ab0f1: Internal refactor of the `react-use` imports to use `react-use/lib/*` instead.
- Updated dependencies
- @backstage/core-plugin-api@0.4.1
- @backstage/plugin-catalog-react@0.6.10
- @backstage/core-components@0.8.3
## 0.1.9
### Patch Changes
- Updated dependencies
- @backstage/core-plugin-api@0.4.0
- @backstage/plugin-catalog-react@0.6.8
- @backstage/core-components@0.8.2
## 0.1.8
### Patch Changes
+8 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-allure",
"description": "A Backstage plugin that integrates with Allure",
"version": "0.1.8",
"version": "0.1.10",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -23,9 +23,9 @@
},
"dependencies": {
"@backstage/catalog-model": "^0.9.7",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/plugin-catalog-react": "^0.6.5",
"@backstage/core-components": "^0.8.3",
"@backstage/core-plugin-api": "^0.4.1",
"@backstage/plugin-catalog-react": "^0.6.10",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
@@ -37,10 +37,10 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@backstage/cli": "^0.10.5",
"@backstage/core-app-api": "^0.3.1",
"@backstage/dev-utils": "^0.2.16",
"@backstage/test-utils": "^0.2.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
@@ -26,7 +26,7 @@ import {
MissingAnnotationEmptyState,
Progress,
} from '@backstage/core-components';
import { useAsync } from 'react-use';
import useAsync from 'react-use/lib/useAsync';
import { Entity } from '@backstage/catalog-model';
const AllureReport = (props: { entity: Entity }) => {
+8
View File
@@ -1,5 +1,13 @@
# @backstage/plugin-analytics-module-ga
## 0.1.5
### Patch Changes
- Updated dependencies
- @backstage/core-plugin-api@0.4.0
- @backstage/core-components@0.8.2
## 0.1.4
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-analytics-module-ga",
"version": "0.1.4",
"version": "0.1.5",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -22,8 +22,8 @@
},
"dependencies": {
"@backstage/config": "^0.1.5",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/core-components": "^0.8.3",
"@backstage/core-plugin-api": "^0.4.1",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
@@ -35,10 +35,10 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@backstage/cli": "^0.10.5",
"@backstage/core-app-api": "^0.3.1",
"@backstage/dev-utils": "^0.2.16",
"@backstage/test-utils": "^0.2.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
+39
View File
@@ -0,0 +1,39 @@
# @backstage/plugin-apache-airflow
## 0.1.2
### Patch Changes
- 4ce51ab0f1: Internal refactor of the `react-use` imports to use `react-use/lib/*` instead.
- Updated dependencies
- @backstage/core-plugin-api@0.4.1
- @backstage/core-components@0.8.3
## 0.1.1
### Patch Changes
- Updated dependencies
- @backstage/core-plugin-api@0.4.0
- @backstage/core-components@0.8.2
## 0.1.0
### Minor Changes
- 9aea335911: Introduces a new plugin for the Apache Airflow workflow management platform.
This implementation has been tested with the Apache Airflow v2 API,
authenticating with basic authentication through the Backstage proxy plugin.
Supported functionality includes:
- Information card of version information of the Airflow instance
- Information card of instance health for the meta-database and scheduler
- Table of DAGs with meta information and status, along with a link to view
details in the Airflow UI
### Patch Changes
- Updated dependencies
- @backstage/core-plugin-api@0.3.1
- @backstage/core-components@0.8.1
+3
View File
@@ -2,6 +2,9 @@
Welcome to the apache-airflow plugin!
This plugin serves as frontend to the REST API exposed by Apache Airflow.
Note only [Airflow v2 (and later)](https://airflow.apache.org/docs/apache-airflow/stable/deprecated-rest-api-ref.html) integrate with the plugin.
## Feature Requests & Ideas
- [ ] Add support for running multiple instances of Airflow for monitoring
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-apache-airflow",
"version": "0.0.0",
"version": "0.1.2",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,8 +20,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/core-components": "^0.8.3",
"@backstage/core-plugin-api": "^0.4.1",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
@@ -33,10 +33,10 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@backstage/cli": "^0.10.5",
"@backstage/core-app-api": "^0.3.1",
"@backstage/dev-utils": "^0.2.16",
"@backstage/test-utils": "^0.2.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
@@ -19,7 +19,6 @@ import { Dag, InstanceStatus, InstanceVersion } from './types';
export const apacheAirflowApiRef = createApiRef<ApacheAirflowApi>({
id: 'plugin.apacheairflow.service',
description: 'Used by the Apache Airflow plugin to make requests',
});
export type ApacheAirflowApi = {
@@ -45,12 +45,12 @@ export class ApacheAirflowClient implements ApacheAirflowApi {
* List all DAGs in the Airflow instance
*
* @remarks
*
* All DAGs with a limit of 100 results per request are returned; this may be
* bogged-down for instances with many DAGs, in which case table pagination
* should be implemented
*
* @param {number} objectsPerRequest records returned per request in pagination
* @returns {Promise<Dag[]>}
* @param objectsPerRequest - records returned per request in pagination
*/
async listDags(options = { objectsPerRequest: 100 }): Promise<Dag[]> {
const dags: Dag[] = [];
@@ -31,7 +31,7 @@ import Typography from '@material-ui/core/Typography';
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
import Alert from '@material-ui/lab/Alert';
import React from 'react';
import { useAsync } from 'react-use';
import useAsync from 'react-use/lib/useAsync';
import { apacheAirflowApiRef } from '../../api';
import { Dag } from '../../api/types';
import { ScheduleIntervalLabel } from '../ScheduleIntervalLabel';
@@ -22,7 +22,7 @@ import {
import { useApi } from '@backstage/core-plugin-api';
import Alert from '@material-ui/lab/Alert';
import React from 'react';
import { useAsync } from 'react-use';
import useAsync from 'react-use/lib/useAsync';
import { apacheAirflowApiRef } from '../../api';
import { InstanceStatus } from '../../api/types';
@@ -22,7 +22,7 @@ import {
import { useApi } from '@backstage/core-plugin-api';
import Alert from '@material-ui/lab/Alert';
import React from 'react';
import { useAsync } from 'react-use';
import useAsync from 'react-use/lib/useAsync';
import { apacheAirflowApiRef } from '../../api';
import { InstanceVersion } from '../../api/types';
+30
View File
@@ -1,5 +1,35 @@
# @backstage/plugin-api-docs
## 0.6.21
### Patch Changes
- 11b81683a9: Support customizing index page layouts via outlets
- Updated dependencies
- @backstage/core-plugin-api@0.4.1
- @backstage/plugin-catalog-react@0.6.10
- @backstage/core-components@0.8.3
- @backstage/plugin-catalog@0.7.7
## 0.6.20
### Patch Changes
- de81b7455e: Display entity title on `ApiDefinitionCard` if defined
- Updated dependencies
- @backstage/plugin-catalog@0.7.6
- @backstage/plugin-catalog-react@0.6.9
## 0.6.19
### Patch Changes
- Updated dependencies
- @backstage/core-plugin-api@0.4.0
- @backstage/plugin-catalog-react@0.6.8
- @backstage/core-components@0.8.2
- @backstage/plugin-catalog@0.7.5
## 0.6.18
### Patch Changes
+23 -19
View File
@@ -5,7 +5,6 @@
```ts
/// <reference types="react" />
import { Action } from '@material-table/core';
import { ApiEntity } from '@backstage/catalog-model';
import { ApiRef } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
@@ -15,6 +14,7 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { default as React_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
import { TableColumn } from '@backstage/core-components';
import { TableProps } from '@backstage/core-components';
import { UserListFilterKind } from '@backstage/plugin-catalog-react';
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
@@ -53,27 +53,17 @@ const apiDocsPlugin: BackstagePlugin<
export { apiDocsPlugin };
export { apiDocsPlugin as plugin };
// @public
export const ApiExplorerIndexPage: (
props: DefaultApiExplorerPageProps,
) => JSX.Element;
// Warning: (ae-missing-release-tag) "ApiExplorerPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const ApiExplorerPage: ({
initiallySelectedFilter,
columns,
actions,
}: {
initiallySelectedFilter?: UserListFilterKind | undefined;
columns?: TableColumn<CatalogTableRow>[] | undefined;
actions?:
| (
| Action<CatalogTableRow>
| {
action: (rowData: CatalogTableRow) => Action<CatalogTableRow>;
position: string;
}
| ((rowData: CatalogTableRow) => Action<CatalogTableRow>)
)[]
| undefined;
}) => JSX.Element;
export const ApiExplorerPage: (
props: DefaultApiExplorerPageProps,
) => JSX.Element;
// Warning: (ae-missing-release-tag) "ApiTypeTitle" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -110,6 +100,20 @@ export const ConsumedApisCard: ({ variant }: Props_2) => JSX.Element;
// @public (undocumented)
export const ConsumingComponentsCard: ({ variant }: Props_5) => JSX.Element;
// @public
export const DefaultApiExplorerPage: ({
initiallySelectedFilter,
columns,
actions,
}: DefaultApiExplorerPageProps) => JSX.Element;
// @public
export type DefaultApiExplorerPageProps = {
initiallySelectedFilter?: UserListFilterKind;
columns?: TableColumn<CatalogTableRow>[];
actions?: TableProps<CatalogTableRow>['actions'];
};
// Warning: (ae-missing-release-tag) "defaultDefinitionWidgets" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
+10 -10
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-api-docs",
"description": "A Backstage plugin that helps represent API entities in the frontend",
"version": "0.6.18",
"version": "0.6.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,12 +30,12 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@asyncapi/react-component": "^1.0.0-next.25",
"@asyncapi/react-component": "1.0.0-next.26",
"@backstage/catalog-model": "^0.9.7",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/plugin-catalog": "^0.7.4",
"@backstage/plugin-catalog-react": "^0.6.5",
"@backstage/core-components": "^0.8.3",
"@backstage/core-plugin-api": "^0.4.1",
"@backstage/plugin-catalog": "^0.7.7",
"@backstage/plugin-catalog-react": "^0.6.10",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
@@ -54,10 +54,10 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@backstage/cli": "^0.10.5",
"@backstage/core-app-api": "^0.3.1",
"@backstage/dev-utils": "^0.2.16",
"@backstage/test-utils": "^0.2.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
@@ -62,6 +62,7 @@ paths:
kind: 'API',
metadata: {
name: 'my-name',
title: 'My Name',
},
spec: {
type: 'openapi',
@@ -88,7 +89,7 @@ paths:
);
await waitFor(() => {
expect(getByText(/my-name/i)).toBeInTheDocument();
expect(getByText(/My Name/i)).toBeInTheDocument();
expect(getByText(/OpenAPI/)).toBeInTheDocument();
expect(getByText(/Raw/i)).toBeInTheDocument();
expect(getByText(/List all artists/i)).toBeInTheDocument();
@@ -101,6 +102,7 @@ paths:
kind: 'API',
metadata: {
name: 'my-name',
title: 'My Name',
},
spec: {
type: 'custom-type',
@@ -118,7 +120,7 @@ paths:
</Wrapper>,
);
expect(getByText(/my-name/i)).toBeInTheDocument();
expect(getByText(/My Name/i)).toBeInTheDocument();
expect(getByText(/custom-type/i)).toBeInTheDocument();
expect(
getAllByText(
@@ -39,10 +39,11 @@ export const ApiDefinitionCard = (_: Props) => {
}
const definitionWidget = getApiDefinitionWidget(entity);
const entityTitle = entity.metadata.title ?? entity.metadata.name;
if (definitionWidget) {
return (
<TabbedCard title={entity.metadata.name}>
<TabbedCard title={entityTitle}>
<CardTab label={definitionWidget.title} key="widget">
{definitionWidget.component(entity.spec.definition)}
</CardTab>
@@ -58,7 +59,7 @@ export const ApiDefinitionCard = (_: Props) => {
return (
<TabbedCard
title={entity.metadata.name}
title={entityTitle}
children={[
// Has to be an array, otherwise typescript doesn't like that this has only a single child
<CardTab label={entity.spec.type} key="raw">
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,185 +14,31 @@
* limitations under the License.
*/
import { Entity, RELATION_MEMBER_OF } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/core-app-api';
import { TableColumn, TableProps } from '@backstage/core-components';
import {
ConfigApi,
configApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { CatalogTableRow } from '@backstage/plugin-catalog';
import {
CatalogApi,
catalogApiRef,
DefaultStarredEntitiesApi,
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import {
MockStorageApi,
TestApiProvider,
wrapInTestApp,
} from '@backstage/test-utils';
import DashboardIcon from '@material-ui/icons/Dashboard';
import { render } from '@testing-library/react';
import React from 'react';
import { apiDocsConfigRef } from '../../config';
import { renderInTestApp } from '@backstage/test-utils';
import { useOutlet } from 'react-router';
import { ApiExplorerPage } from './ApiExplorerPage';
describe('ApiCatalogPage', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity1',
},
spec: { type: 'openapi' },
},
] as Entity[],
}),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
getEntityByName: async entityName => {
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: { name: entityName.name },
relations: [
{
type: RELATION_MEMBER_OF,
target: { namespace: 'default', kind: 'Group', name: 'tools' },
},
],
};
},
};
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useOutlet: jest.fn().mockReturnValue('Route Children'),
}));
const configApi: ConfigApi = new ConfigReader({
organization: {
name: 'My Company',
},
jest.mock('./DefaultApiExplorerPage', () => ({
DefaultApiExplorerPage: jest.fn().mockReturnValue('DefaultApiExplorerPage'),
}));
describe('ApiExplorerPage', () => {
it('renders provided router element', async () => {
const { getByText } = await renderInTestApp(<ApiExplorerPage />);
expect(getByText('Route Children')).toBeInTheDocument();
});
const apiDocsConfig = {
getApiDefinitionWidget: () => undefined,
};
it('renders DefaultApiExplorerPage home when no router children are provided', async () => {
(useOutlet as jest.Mock).mockReturnValueOnce(null);
const { getByText } = await renderInTestApp(<ApiExplorerPage />);
const storageApi = MockStorageApi.create();
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[configApiRef, configApi],
[storageApiRef, storageApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({ storageApi }),
],
[apiDocsConfigRef, apiDocsConfig],
]}
>
{children}
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
),
);
// 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(<ApiExplorerPage />);
expect(await findByText(/My Company API Explorer/)).toBeInTheDocument();
});
it('should render the default column of the grid', async () => {
const { getAllByRole } = renderWrapped(<ApiExplorerPage />);
const columnHeader = getAllByRole('button').filter(
c => c.tagName === 'SPAN',
);
const columnHeaderLabels = columnHeader.map(c => c.textContent);
expect(columnHeaderLabels).toEqual([
'Name',
'System',
'Owner',
'Type',
'Lifecycle',
'Description',
'Tags',
'Actions',
]);
});
it('should render the custom column passed as prop', async () => {
const columns: TableColumn<CatalogTableRow>[] = [
{ title: 'Foo', field: 'entity.foo' },
{ title: 'Bar', field: 'entity.bar' },
{ title: 'Baz', field: 'entity.spec.lifecycle' },
];
const { getAllByRole } = renderWrapped(
<ApiExplorerPage columns={columns} />,
);
const columnHeader = getAllByRole('button').filter(
c => c.tagName === 'SPAN',
);
const columnHeaderLabels = columnHeader.map(c => c.textContent);
expect(columnHeaderLabels).toEqual(['Foo', 'Bar', 'Baz', 'Actions']);
});
it('should render the default actions of an item in the grid', async () => {
const { findByTitle, findByText } = await renderWrapped(
<ApiExplorerPage />,
);
expect(await findByText(/All \(1\)/)).toBeInTheDocument();
expect(await findByTitle(/View/)).toBeInTheDocument();
expect(await findByTitle(/View/)).toBeInTheDocument();
expect(await findByTitle(/Edit/)).toBeInTheDocument();
expect(await findByTitle(/Add to favorites/)).toBeInTheDocument();
});
it('should render the custom actions of an item passed as prop', async () => {
const actions: TableProps<CatalogTableRow>['actions'] = [
() => {
return {
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Foo Action',
disabled: false,
onClick: () => jest.fn(),
};
},
() => {
return {
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Bar Action',
disabled: true,
onClick: () => jest.fn(),
};
},
];
const { findByTitle, findByText } = await renderWrapped(
<ApiExplorerPage actions={actions} />,
);
expect(await findByText(/All \(1\)/)).toBeInTheDocument();
expect(await findByTitle(/Foo Action/)).toBeInTheDocument();
expect(await findByTitle(/Bar Action/)).toBeInTheDocument();
expect((await findByTitle(/Bar Action/)).firstChild).toBeDisabled();
expect(getByText('DefaultApiExplorerPage')).toBeInTheDocument();
});
});
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,97 +14,19 @@
* limitations under the License.
*/
import {
Content,
ContentHeader,
CreateButton,
PageWithHeader,
SupportButton,
TableColumn,
TableProps,
} from '@backstage/core-components';
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
CatalogTable,
CatalogTableRow,
FilteredEntityLayout,
EntityListContainer,
FilterContainer,
} from '@backstage/plugin-catalog';
import {
EntityKindPicker,
EntityLifecyclePicker,
EntityListProvider,
EntityOwnerPicker,
EntityTagPicker,
EntityTypePicker,
UserListFilterKind,
UserListPicker,
} from '@backstage/plugin-catalog-react';
import React from 'react';
import { createComponentRouteRef } from '../../routes';
import { useOutlet } from 'react-router';
import {
DefaultApiExplorerPage,
DefaultApiExplorerPageProps,
} from './DefaultApiExplorerPage';
const defaultColumns: TableColumn<CatalogTableRow>[] = [
CatalogTable.columns.createNameColumn({ defaultKind: 'API' }),
CatalogTable.columns.createSystemColumn(),
CatalogTable.columns.createOwnerColumn(),
CatalogTable.columns.createSpecTypeColumn(),
CatalogTable.columns.createSpecLifecycleColumn(),
CatalogTable.columns.createMetadataDescriptionColumn(),
CatalogTable.columns.createTagsColumn(),
];
/**
* ApiExplorerPage
* @public
*/
export const ApiExplorerPage = (props: DefaultApiExplorerPageProps) => {
const outlet = useOutlet();
type ApiExplorerPageProps = {
initiallySelectedFilter?: UserListFilterKind;
columns?: TableColumn<CatalogTableRow>[];
actions?: TableProps<CatalogTableRow>['actions'];
};
export const ApiExplorerPage = ({
initiallySelectedFilter = 'all',
columns,
actions,
}: ApiExplorerPageProps) => {
const configApi = useApi(configApiRef);
const generatedSubtitle = `${
configApi.getOptionalString('organization.name') ?? 'Backstage'
} API Explorer`;
const createComponentLink = useRouteRef(createComponentRouteRef);
return (
<PageWithHeader
themeId="apis"
title="APIs"
subtitle={generatedSubtitle}
pageTitleOverride="APIs"
>
<Content>
<ContentHeader title="">
<CreateButton
title="Register Existing API"
to={createComponentLink?.()}
/>
<SupportButton>All your APIs</SupportButton>
</ContentHeader>
<EntityListProvider>
<FilteredEntityLayout>
<FilterContainer>
<EntityKindPicker initialFilter="api" hidden />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityOwnerPicker />
<EntityLifecyclePicker />
<EntityTagPicker />
</FilterContainer>
<EntityListContainer>
<CatalogTable
columns={columns || defaultColumns}
actions={actions}
/>
</EntityListContainer>
</FilteredEntityLayout>
</EntityListProvider>
</Content>
</PageWithHeader>
);
return outlet || <DefaultApiExplorerPage {...props} />;
};
@@ -0,0 +1,198 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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, RELATION_MEMBER_OF } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/core-app-api';
import { TableColumn, TableProps } from '@backstage/core-components';
import {
ConfigApi,
configApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { CatalogTableRow } from '@backstage/plugin-catalog';
import {
CatalogApi,
catalogApiRef,
DefaultStarredEntitiesApi,
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import {
MockStorageApi,
TestApiProvider,
wrapInTestApp,
} from '@backstage/test-utils';
import DashboardIcon from '@material-ui/icons/Dashboard';
import { render } from '@testing-library/react';
import React from 'react';
import { apiDocsConfigRef } from '../../config';
import { DefaultApiExplorerPage } from './DefaultApiExplorerPage';
describe('DefaultApiExplorerPage', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({
items: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity1',
},
spec: { type: 'openapi' },
},
] as Entity[],
}),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
getEntityByName: async entityName => {
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: { name: entityName.name },
relations: [
{
type: RELATION_MEMBER_OF,
target: { namespace: 'default', kind: 'Group', name: 'tools' },
},
],
};
},
};
const configApi: ConfigApi = new ConfigReader({
organization: {
name: 'My Company',
},
});
const apiDocsConfig = {
getApiDefinitionWidget: () => undefined,
};
const storageApi = MockStorageApi.create();
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<TestApiProvider
apis={[
[catalogApiRef, catalogApi],
[configApiRef, configApi],
[storageApiRef, storageApi],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({ storageApi }),
],
[apiDocsConfigRef, apiDocsConfig],
]}
>
{children}
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
),
);
// 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(<DefaultApiExplorerPage />);
expect(await findByText(/My Company API Explorer/)).toBeInTheDocument();
});
it('should render the default column of the grid', async () => {
const { getAllByRole } = renderWrapped(<DefaultApiExplorerPage />);
const columnHeader = getAllByRole('button').filter(
c => c.tagName === 'SPAN',
);
const columnHeaderLabels = columnHeader.map(c => c.textContent);
expect(columnHeaderLabels).toEqual([
'Name',
'System',
'Owner',
'Type',
'Lifecycle',
'Description',
'Tags',
'Actions',
]);
});
it('should render the custom column passed as prop', async () => {
const columns: TableColumn<CatalogTableRow>[] = [
{ title: 'Foo', field: 'entity.foo' },
{ title: 'Bar', field: 'entity.bar' },
{ title: 'Baz', field: 'entity.spec.lifecycle' },
];
const { getAllByRole } = renderWrapped(
<DefaultApiExplorerPage columns={columns} />,
);
const columnHeader = getAllByRole('button').filter(
c => c.tagName === 'SPAN',
);
const columnHeaderLabels = columnHeader.map(c => c.textContent);
expect(columnHeaderLabels).toEqual(['Foo', 'Bar', 'Baz', 'Actions']);
});
it('should render the default actions of an item in the grid', async () => {
const { findByTitle, findByText } = await renderWrapped(
<DefaultApiExplorerPage />,
);
expect(await findByText(/All \(1\)/)).toBeInTheDocument();
expect(await findByTitle(/View/)).toBeInTheDocument();
expect(await findByTitle(/View/)).toBeInTheDocument();
expect(await findByTitle(/Edit/)).toBeInTheDocument();
expect(await findByTitle(/Add to favorites/)).toBeInTheDocument();
});
it('should render the custom actions of an item passed as prop', async () => {
const actions: TableProps<CatalogTableRow>['actions'] = [
() => {
return {
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Foo Action',
disabled: false,
onClick: () => jest.fn(),
};
},
() => {
return {
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Bar Action',
disabled: true,
onClick: () => jest.fn(),
};
},
];
const { findByTitle, findByText } = await renderWrapped(
<DefaultApiExplorerPage actions={actions} />,
);
expect(await findByText(/All \(1\)/)).toBeInTheDocument();
expect(await findByTitle(/Foo Action/)).toBeInTheDocument();
expect(await findByTitle(/Bar Action/)).toBeInTheDocument();
expect((await findByTitle(/Bar Action/)).firstChild).toBeDisabled();
});
});
@@ -0,0 +1,118 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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,
ContentHeader,
CreateButton,
PageWithHeader,
SupportButton,
TableColumn,
TableProps,
} from '@backstage/core-components';
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
import {
CatalogTable,
CatalogTableRow,
FilteredEntityLayout,
EntityListContainer,
FilterContainer,
} from '@backstage/plugin-catalog';
import {
EntityKindPicker,
EntityLifecyclePicker,
EntityListProvider,
EntityOwnerPicker,
EntityTagPicker,
EntityTypePicker,
UserListFilterKind,
UserListPicker,
} from '@backstage/plugin-catalog-react';
import React from 'react';
import { createComponentRouteRef } from '../../routes';
const defaultColumns: TableColumn<CatalogTableRow>[] = [
CatalogTable.columns.createNameColumn({ defaultKind: 'API' }),
CatalogTable.columns.createSystemColumn(),
CatalogTable.columns.createOwnerColumn(),
CatalogTable.columns.createSpecTypeColumn(),
CatalogTable.columns.createSpecLifecycleColumn(),
CatalogTable.columns.createMetadataDescriptionColumn(),
CatalogTable.columns.createTagsColumn(),
];
/**
* DefaultApiExplorerPageProps
* @public
*/
export type DefaultApiExplorerPageProps = {
initiallySelectedFilter?: UserListFilterKind;
columns?: TableColumn<CatalogTableRow>[];
actions?: TableProps<CatalogTableRow>['actions'];
};
/**
* DefaultApiExplorerPage
* @public
*/
export const DefaultApiExplorerPage = ({
initiallySelectedFilter = 'all',
columns,
actions,
}: DefaultApiExplorerPageProps) => {
const configApi = useApi(configApiRef);
const generatedSubtitle = `${
configApi.getOptionalString('organization.name') ?? 'Backstage'
} API Explorer`;
const createComponentLink = useRouteRef(createComponentRouteRef);
return (
<PageWithHeader
themeId="apis"
title="APIs"
subtitle={generatedSubtitle}
pageTitleOverride="APIs"
>
<Content>
<ContentHeader title="">
<CreateButton
title="Register Existing API"
to={createComponentLink?.()}
/>
<SupportButton>All your APIs</SupportButton>
</ContentHeader>
<EntityListProvider>
<FilteredEntityLayout>
<FilterContainer>
<EntityKindPicker initialFilter="api" hidden />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityOwnerPicker />
<EntityLifecyclePicker />
<EntityTagPicker />
</FilterContainer>
<EntityListContainer>
<CatalogTable
columns={columns || defaultColumns}
actions={actions}
/>
</EntityListContainer>
</FilteredEntityLayout>
</EntityListProvider>
</Content>
</PageWithHeader>
);
};
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,4 +14,6 @@
* limitations under the License.
*/
export { ApiExplorerPage } from './ApiExplorerPage';
export { ApiExplorerPage as ApiExplorerIndexPage } from './ApiExplorerPage';
export { DefaultApiExplorerPage } from './DefaultApiExplorerPage';
export type { DefaultApiExplorerPageProps } from './DefaultApiExplorerPage';
+1
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export * from './ApiExplorerPage';
export * from './ApiDefinitionCard';
export * from './ApisCards';
export * from './AsyncApiDefinitionWidget';
-1
View File
@@ -20,7 +20,6 @@ import { createApiRef } from '@backstage/core-plugin-api';
export const apiDocsConfigRef = createApiRef<ApiDocsConfig>({
id: 'plugin.api-docs.config',
description: 'Used to configure api-docs widgets',
});
export interface ApiDocsConfig {
+1 -1
View File
@@ -53,7 +53,7 @@ export const ApiExplorerPage = apiDocsPlugin.provide(
createRoutableExtension({
name: 'ApiExplorerPage',
component: () =>
import('./components/ApiExplorerPage').then(m => m.ApiExplorerPage),
import('./components/ApiExplorerPage').then(m => m.ApiExplorerIndexPage),
mountPoint: rootRoute,
}),
);
+17
View File
@@ -1,5 +1,22 @@
# @backstage/plugin-app-backend
## 0.3.21
### Patch Changes
- 9d9cfc1b8a: Set `X-Frame-Options: deny` rather than the default `sameorigin` for all content served by the `app-backend`.`
- Updated dependencies
- @backstage/backend-common@0.10.1
- @backstage/config-loader@0.9.1
## 0.3.20
### Patch Changes
- Updated dependencies
- @backstage/backend-common@0.10.0
- @backstage/config-loader@0.9.0
## 0.3.19
### Patch Changes
+5 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-app-backend",
"description": "A Backstage backend plugin that serves the Backstage frontend app",
"version": "0.3.19",
"version": "0.3.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,19 +30,20 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.12",
"@backstage/config-loader": "^0.8.1",
"@backstage/backend-common": "^0.10.1",
"@backstage/config-loader": "^0.9.1",
"@backstage/config": "^0.1.11",
"@backstage/types": "^0.1.1",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fs-extra": "9.1.0",
"helmet": "^4.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.0",
"@backstage/cli": "^0.10.4",
"@backstage/types": "^0.1.1",
"@types/supertest": "^2.0.8",
"msw": "^0.35.0",
@@ -16,6 +16,7 @@
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import helmet from 'helmet';
import express from 'express';
import Router from 'express-promise-router';
import fs from 'fs-extra';
@@ -89,6 +90,8 @@ export async function createRouter(
const router = Router();
router.use(helmet.frameguard({ action: 'deny' }));
// 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')));
+67
View File
@@ -1,5 +1,72 @@
# @backstage/plugin-auth-backend
## 0.6.1
### Patch Changes
- e0e57817d2: Added Google Cloud Identity-Aware Proxy as an identity provider.
- Updated dependencies
- @backstage/backend-common@0.10.2
## 0.6.0
### Minor Changes
- c88cdacc1a: Avoid ever returning OAuth refresh tokens back to the client, and always exchange refresh tokens for a new one when available for all providers.
This comes with a breaking change to the TypeScript API for custom auth providers. The `refresh` method of `OAuthHandlers` implementation must now return a `{ response, refreshToken }` object rather than a direct response. Existing `refresh` implementations are typically migrated by changing an existing return expression that looks like this:
```ts
return await this.handleResult({
fullProfile,
params,
accessToken,
refreshToken,
});
```
Into the following:
```ts
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
```
### Patch Changes
- f0f81f6cc7: Replaces the usage of `got` with `node-fetch` in the `getUserPhoto` method of the Microsoft provider
- 2f26120a36: Update `auth0` and `onelogin` providers to allow for `authHandler` and `signIn.resolver` configuration.
- a9abafa9df: Fixed bug on refresh token on Okta provider, now it gets the refresh token and it sends it into providerInfo
- eb48e78886: Enforce cookie SSL protection when in production for auth-backend sessions
- Updated dependencies
- @backstage/test-utils@0.2.1
- @backstage/backend-common@0.10.1
## 0.5.2
### Patch Changes
- 24a67e3e2e: Fixed the fallback identity population to correctly generate an entity reference for `userEntityRef` if no token is provided.
- Updated dependencies
- @backstage/backend-common@0.10.0
- @backstage/test-utils@0.2.0
- @backstage/catalog-client@0.5.3
## 0.5.1
### Patch Changes
- 699c2e9ddc: export minimal typescript types for OIDC provider
- Updated dependencies
- @backstage/backend-common@0.9.14
- @backstage/catalog-model@0.9.8
## 0.5.0
### Minor Changes
+69 -48
View File
@@ -9,6 +9,7 @@ import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
import express from 'express';
import { JsonValue } from '@backstage/types';
import { JSONWebKey } from 'jose';
import { Logger as Logger_2 } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
@@ -27,10 +28,13 @@ export class AtlassianAuthProvider implements OAuthHandlers {
// (undocumented)
handler(req: express.Request): Promise<{
response: OAuthResponse;
refreshToken: string;
refreshToken: string | undefined;
}>;
// (undocumented)
refresh(req: OAuthRefreshRequest): Promise<OAuthResponse>;
refresh(req: OAuthRefreshRequest): Promise<{
response: OAuthResponse;
refreshToken: string | undefined;
}>;
// Warning: (ae-forgotten-export) The symbol "RedirectInfo" needs to be exported by the entry point index.d.ts
//
// (undocumented)
@@ -47,9 +51,17 @@ export type AtlassianProviderOptions = {
};
};
// @public (undocumented)
export type Auth0ProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
signIn?: {
resolver: SignInResolver<OAuthResult>;
};
};
// @public
export type AuthHandler<AuthResult> = (
input: AuthResult,
export type AuthHandler<TAuthResult> = (
input: TAuthResult,
) => Promise<AuthHandlerResult>;
// @public
@@ -77,33 +89,13 @@ export type AuthProviderFactoryOptions = {
catalogApi: CatalogApi;
};
// Warning: (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
// Warning: (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
// Warning: (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
// Warning: (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
// Warning: (ae-missing-release-tag) "AuthProviderRouteHandlers" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface AuthProviderRouteHandlers {
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
frameHandler(req: express.Request, res: express.Response): Promise<void>;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
logout?(req: express.Request, res: express.Response): Promise<void>;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
refresh?(req: express.Request, res: express.Response): Promise<void>;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
start(req: express.Request, res: express.Response): Promise<void>;
}
@@ -219,6 +211,11 @@ export const createAtlassianProvider: (
options?: AtlassianProviderOptions | undefined,
) => AuthProviderFactory;
// @public (undocumented)
export const createAuth0Provider: (
options?: Auth0ProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createAwsAlbProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -233,6 +230,11 @@ export const createBitbucketProvider: (
options?: BitbucketProviderOptions | undefined,
) => AuthProviderFactory;
// @public
export function createGcpIapProvider(
options: GcpIapProviderOptions,
): AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createGithubProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -282,6 +284,11 @@ export const createOktaProvider: (
_options?: OktaProviderOptions | undefined,
) => AuthProviderFactory;
// @public (undocumented)
export const createOneLoginProvider: (
options?: OneLoginProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createOriginFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -314,6 +321,26 @@ export const encodeState: (state: OAuthState) => string;
// @public (undocumented)
export const ensuresXRequestedWith: (req: express.Request) => boolean;
// @public
export type GcpIapProviderOptions = {
authHandler?: AuthHandler<GcpIapResult>;
signIn: {
resolver: SignInResolver<GcpIapResult>;
};
};
// @public
export type GcpIapResult = {
iapToken: GcpIapTokenInfo;
};
// @public
export type GcpIapTokenInfo = {
sub: string;
email: string;
[key: string]: JsonValue;
};
// Warning: (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "getEntityClaims" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -455,25 +482,17 @@ export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
start(req: express.Request, res: express.Response): Promise<void>;
}
// Warning: (ae-missing-release-tag) "OAuthHandlers" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface OAuthHandlers {
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
handler(req: express.Request): Promise<{
response: OAuthResponse;
refreshToken?: string;
}>;
logout?(): Promise<void>;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
refresh?(req: OAuthRefreshRequest): Promise<OAuthResponse>;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
refresh?(req: OAuthRefreshRequest): Promise<{
response: OAuthResponse;
refreshToken?: string;
}>;
start(req: OAuthStartRequest): Promise<RedirectInfo>;
}
@@ -485,7 +504,6 @@ export type OAuthProviderInfo = {
idToken?: string;
expiresInSeconds?: number;
scope: string;
refreshToken?: string;
};
// Warning: (ae-missing-release-tag) "OAuthProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -572,6 +590,14 @@ export type OktaProviderOptions = {
};
};
// @public (undocumented)
export type OneLoginProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
signIn?: {
resolver: SignInResolver<OAuthResult>;
};
};
// Warning: (ae-missing-release-tag) "postMessageResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -630,14 +656,14 @@ export type SamlProviderOptions = {
};
// @public
export type SignInInfo<AuthResult> = {
export type SignInInfo<TAuthResult> = {
profile: ProfileInfo;
result: AuthResult;
result: TAuthResult;
};
// @public
export type SignInResolver<AuthResult> = (
info: SignInInfo<AuthResult>,
export type SignInResolver<TAuthResult> = (
info: SignInInfo<TAuthResult>,
context: {
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
@@ -677,11 +703,6 @@ export type WebMessageResponse =
//
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts
// src/providers/github/provider.d.ts:71:58 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
// src/providers/github/provider.d.ts:71:90 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
// src/providers/github/provider.d.ts:71:89 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag
// src/providers/github/provider.d.ts:71:67 - (tsdoc-malformed-html-name) Invalid HTML element: Expecting an HTML name
// src/providers/github/provider.d.ts:71:68 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@"
// src/providers/github/provider.d.ts:78:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:100:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
// src/providers/github/provider.d.ts:81:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:88:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
```
+10 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-auth-backend",
"description": "A Backstage backend plugin that handles authentication",
"version": "0.5.0",
"version": "0.6.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,12 +30,12 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.13",
"@backstage/catalog-client": "^0.5.2",
"@backstage/catalog-model": "^0.9.7",
"@backstage/backend-common": "^0.10.2",
"@backstage/catalog-client": "^0.5.3",
"@backstage/catalog-model": "^0.9.8",
"@backstage/config": "^0.1.11",
"@backstage/errors": "^0.1.5",
"@backstage/test-utils": "^0.1.24",
"@backstage/types": "^0.1.1",
"@google-cloud/firestore": "^4.15.1",
"@types/express": "^4.17.6",
"@types/passport": "^1.0.3",
@@ -46,7 +46,7 @@
"express-promise-router": "^4.1.0",
"express-session": "^1.17.1",
"fs-extra": "9.1.0",
"got": "^11.5.2",
"google-auth-library": "^7.6.1",
"helmet": "^4.0.0",
"jose": "^1.27.1",
"jwt-decode": "^3.1.0",
@@ -73,7 +73,8 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/cli": "^0.10.5",
"@backstage/test-utils": "^0.2.1",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/express-session": "^1.17.2",
@@ -84,7 +85,8 @@
"@types/passport-saml": "^1.1.3",
"@types/passport-strategy": "^0.2.35",
"@types/xml2js": "^0.4.7",
"msw": "^0.35.0"
"msw": "^0.35.0",
"supertest": "^6.1.3"
},
"files": [
"dist",
@@ -17,7 +17,7 @@
import express from 'express';
import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter';
import { encodeState } from './helpers';
import { OAuthHandlers } from './types';
import { OAuthHandlers, OAuthResponse } from './types';
const mockResponseData = {
providerInfo: {
@@ -36,6 +36,12 @@ const mockResponseData = {
},
};
function mkTokenBody(payload: unknown): string {
return Buffer.from(JSON.stringify(payload), 'utf8')
.toString('base64')
.replace(/=/g, '');
}
describe('OAuthAdapter', () => {
class MyAuthProvider implements OAuthHandlers {
async start() {
@@ -51,7 +57,10 @@ describe('OAuthAdapter', () => {
};
}
async refresh() {
return mockResponseData;
return {
response: mockResponseData,
refreshToken: 'token',
};
}
}
const providerInstance = new MyAuthProvider();
@@ -249,4 +258,93 @@ describe('OAuthAdapter', () => {
'Refresh token is not supported for provider test-provider',
);
});
it('correctly populates incomplete identities', async () => {
const mockRefresh = jest.fn<
Promise<{ response: OAuthResponse }>,
[express.Request]
>();
const oauthProvider = new OAuthAdapter(
{
refresh: mockRefresh,
start: jest.fn(),
handler: jest.fn(),
} as OAuthHandlers,
{
...oAuthProviderOptions,
tokenIssuer: {
issueToken: async ({ claims }) => `a.${mkTokenBody(claims)}.a`,
listPublicKeys: async () => ({ keys: [] }),
},
disableRefresh: false,
isOriginAllowed: () => false,
},
);
const mockRequest = {
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'token',
},
query: {},
} as unknown as express.Request;
const mockResponse = {
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
} as unknown as express.Response;
// Without a token
mockRefresh.mockResolvedValueOnce({
response: {
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: '',
},
},
});
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledTimes(1);
expect(mockResponse.json).toHaveBeenLastCalledWith({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`,
idToken: `a.${mkTokenBody({ sub: 'user:default/foo' })}.a`,
identity: {
type: 'user',
userEntityRef: 'user:default/foo',
ownershipEntityRefs: [],
},
},
});
// With a token
mockRefresh.mockResolvedValueOnce({
response: {
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
},
},
});
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledTimes(2);
expect(mockResponse.json).toHaveBeenLastCalledWith({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
idToken: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
identity: {
type: 'user',
userEntityRef: 'user:my-ns/foo',
ownershipEntityRefs: [],
},
},
});
});
});
@@ -17,6 +17,11 @@
import express from 'express';
import crypto from 'crypto';
import { URL } from 'url';
import {
ENTITY_DEFAULT_NAMESPACE,
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
AuthProviderRouteHandlers,
AuthProviderConfig,
@@ -207,19 +212,15 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
const forwardReq = Object.assign(req, { scope, refreshToken });
// get new access_token
const response = await this.handlers.refresh(
forwardReq as OAuthRefreshRequest,
);
const { response, refreshToken: newRefreshToken } =
await this.handlers.refresh(forwardReq as OAuthRefreshRequest);
const backstageIdentity = await this.populateIdentity(
response.backstageIdentity,
);
if (
response.providerInfo.refreshToken &&
response.providerInfo.refreshToken !== refreshToken
) {
this.setRefreshTokenCookie(res, response.providerInfo.refreshToken);
if (newRefreshToken && newRefreshToken !== refreshToken) {
this.setRefreshTokenCookie(res, newRefreshToken);
}
res.status(200).json({ ...response, backstageIdentity });
@@ -243,8 +244,14 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
return prepareBackstageIdentityResponse(identity);
}
const userEntityRef = stringifyEntityRef(
parseEntityRef(identity.id, {
defaultKind: 'user',
defaultNamespace: ENTITY_DEFAULT_NAMESPACE,
}),
);
const token = await this.options.tokenIssuer.issueToken({
claims: { sub: identity.id },
claims: { sub: userEntityRef },
});
return prepareBackstageIdentityResponse({ ...identity, token });
+8 -12
View File
@@ -79,10 +79,6 @@ export type OAuthProviderInfo = {
* Scopes granted for the access token.
*/
scope: string;
/**
* A refresh token issued for the signed in user
*/
refreshToken?: string;
};
export type OAuthState = {
@@ -107,18 +103,17 @@ export type OAuthRefreshRequest = express.Request<{}> & {
* 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.
*
* @public
*/
export interface OAuthHandlers {
/**
* This method initiates a sign in request with an auth provider.
* @param {express.Request} req
* @param options
* Initiate a sign in request with an auth provider.
*/
start(req: OAuthStartRequest): Promise<RedirectInfo>;
/**
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
* Handle the redirect from the auth provider when the user has signed in.
*/
handler(req: express.Request): Promise<{
response: OAuthResponse;
@@ -127,10 +122,11 @@ export interface OAuthHandlers {
/**
* (Optional) Given a refresh token and scope fetches a new access token from the auth provider.
* @param {string} refreshToken
* @param {string} scope
*/
refresh?(req: OAuthRefreshRequest): Promise<OAuthResponse>;
refresh?(req: OAuthRefreshRequest): Promise<{
response: OAuthResponse;
refreshToken?: string;
}>;
/**
* (Optional) Sign out of the auth provider.
@@ -78,20 +78,22 @@ describe('createAtlassianProvider', () => {
refreshToken: 'wacka',
},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual({
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
refreshToken: 'wacka',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
picture: 'http://google.com/lols',
const result = await provider.handler({} as any);
expect(result).toEqual({
response: {
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
picture: 'http://google.com/lols',
},
},
refreshToken: 'wacka',
});
});
@@ -127,20 +129,22 @@ describe('createAtlassianProvider', () => {
],
});
const response = await provider.refresh({} as any);
const result = await provider.refresh({} as any);
expect(response).toEqual({
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: 'http://google.com/lols',
},
providerInfo: {
accessToken: 'a.b.c',
idToken: 'my-id',
refreshToken: 'dont-forget-to-send-refresh',
scope: 'read_user',
expect(result).toEqual({
response: {
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: 'http://google.com/lols',
},
providerInfo: {
accessToken: 'a.b.c',
idToken: 'my-id',
scope: 'read_user',
},
},
refreshToken: 'dont-forget-to-send-refresh',
});
});
});
@@ -107,9 +107,7 @@ export class AtlassianAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result } = await executeFrameHandlerStrategy<OAuthResult>(
req,
this._strategy,
@@ -117,7 +115,7 @@ export class AtlassianAuthProvider implements OAuthHandlers {
return {
response: await this.handleResult(result),
refreshToken: result.refreshToken ?? '',
refreshToken: result.refreshToken,
};
}
@@ -128,7 +126,6 @@ export class AtlassianAuthProvider implements OAuthHandlers {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
@@ -152,28 +149,27 @@ export class AtlassianAuthProvider implements OAuthHandlers {
return response;
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const {
accessToken,
params,
refreshToken: newRefreshToken,
} = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, params, refreshToken } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: newRefreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
}
@@ -36,7 +36,15 @@ import {
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
RedirectInfo,
AuthProviderFactory,
AuthHandler,
SignInResolver,
} from '../types';
import { CatalogIdentityClient } from '../../lib/catalog';
import { TokenIssuer } from '../../identity';
import { Logger } from 'winston';
type PrivateInfo = {
refreshToken: string;
@@ -44,12 +52,27 @@ type PrivateInfo = {
export type Auth0AuthProviderOptions = OAuthProviderOptions & {
domain: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class Auth0AuthProvider implements OAuthHandlers {
private readonly _strategy: Auth0Strategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: Auth0AuthProviderOptions) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.tokenIssuer = options.tokenIssuer;
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this._strategy = new Auth0Strategy(
{
clientID: options.clientId,
@@ -90,88 +113,144 @@ export class Auth0AuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
>(req, this._strategy);
const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
return {
response: await this.populateIdentity({
profile,
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
}),
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
const profile = makeProfileInfo(fullProfile, params.id_token);
return this.populateIdentity({
providerInfo: {
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
});
}),
refreshToken,
};
}
// 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;
private async handleResult(result: OAuthResult) {
const { profile } = await this.authHandler(result);
if (!profile.email) {
throw new Error('Profile does not contain an email');
const response: OAuthResponse = {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
}
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id, token: '' } };
return response;
}
}
export type Auth0ProviderOptions = {};
const defaultSignInResolver: SignInResolver<OAuthResult> = async info => {
const { profile } = info;
if (!profile.email) {
throw new Error('Profile does not contain an email');
}
const id = profile.email.split('@')[0];
return { id, token: '' };
};
/** @public */
export type Auth0ProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<OAuthResult>;
};
};
/** @public */
export const createAuth0Provider = (
_options?: Auth0ProviderOptions,
options?: Auth0ProviderOptions,
): AuthProviderFactory => {
return ({ providerId, globalConfig, config, tokenIssuer }) =>
return ({
providerId,
globalConfig,
config,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const domain = envConfig.getString('domain');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolver = options?.signIn?.resolver ?? defaultSignInResolver;
const provider = new Auth0AuthProvider({
clientId,
clientSecret,
callbackUrl,
domain,
authHandler,
signInResolver,
tokenIssuer,
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -13,5 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createAwsAlbProvider } from './provider';
export type { AwsAlbProviderOptions } from './provider';
@@ -18,7 +18,7 @@ import express from 'express';
import { JWT } from 'jose';
import {
ALB_ACCESSTOKEN_HEADER,
ALB_ACCESS_TOKEN_HEADER,
ALB_JWT_HEADER,
AwsAlbAuthProvider,
} from './provider';
@@ -80,7 +80,7 @@ describe('AwsAlbAuthProvider', () => {
header: jest.fn(name => {
if (name === ALB_JWT_HEADER) {
return mockJwt;
} else if (name === ALB_ACCESSTOKEN_HEADER) {
} else if (name === ALB_ACCESS_TOKEN_HEADER) {
return mockAccessToken;
}
return undefined;
@@ -88,7 +88,7 @@ describe('AwsAlbAuthProvider', () => {
} as unknown as express.Request;
const mockRequestWithoutJwt = {
header: jest.fn(name => {
if (name === ALB_ACCESSTOKEN_HEADER) {
if (name === ALB_ACCESS_TOKEN_HEADER) {
return mockAccessToken;
}
return undefined;
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AuthHandler,
AuthProviderFactory,
@@ -35,7 +36,7 @@ import { AuthenticationError } from '@backstage/errors';
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
export const ALB_JWT_HEADER = 'x-amzn-oidc-data';
export const ALB_ACCESSTOKEN_HEADER = 'x-amzn-oidc-accesstoken';
export const ALB_ACCESS_TOKEN_HEADER = 'x-amzn-oidc-accesstoken';
type Options = {
region: string;
@@ -134,7 +135,7 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
private async getResult(req: express.Request): Promise<AwsAlbResult> {
const jwt = req.header(ALB_JWT_HEADER);
const accessToken = req.header(ALB_ACCESSTOKEN_HEADER);
const accessToken = req.header(ALB_ACCESS_TOKEN_HEADER);
if (jwt === undefined) {
throw new AuthenticationError(
@@ -144,7 +145,7 @@ export class AwsAlbAuthProvider implements AuthProviderRouteHandlers {
if (accessToken === undefined) {
throw new AuthenticationError(
`Missing ALB OIDC header: ${ALB_ACCESSTOKEN_HEADER}`,
`Missing ALB OIDC header: ${ALB_ACCESS_TOKEN_HEADER}`,
);
}
@@ -138,9 +138,7 @@ export class BitbucketAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -152,22 +150,25 @@ export class BitbucketAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: BitbucketOAuthResult) {
@@ -0,0 +1,134 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { ConflictError } from '@backstage/errors';
import { OAuth2Client } from 'google-auth-library';
import { createTokenValidator, parseRequestToken } from './helpers';
const validJwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo';
beforeEach(() => {
jest.clearAllMocks();
});
describe('helpers', () => {
describe('createTokenValidator', () => {
it('runs the happy path', async () => {
const mockClient = {
getIapPublicKeys: async () => ({ pubkeys: '' }),
verifySignedJwtWithCertsAsync: async () => ({
getPayload: () => ({ sub: 's', email: 'e@mail.com' }),
}),
};
const validator = createTokenValidator(
'a',
mockClient as unknown as OAuth2Client,
);
await expect(validator(validJwt)).resolves.toMatchObject({
sub: 's',
email: 'e@mail.com',
});
});
it('throws if the client throws', async () => {
const mockClient = {
getIapPublicKeys: async () => {
throw new TypeError('bam');
},
};
const validator = createTokenValidator(
'a',
mockClient as unknown as OAuth2Client,
);
await expect(validator(validJwt)).rejects.toThrowError(TypeError);
});
it('rejects empty payload', async () => {
const mockClient = {
getIapPublicKeys: async () => ({ pubkeys: '' }),
verifySignedJwtWithCertsAsync: async () => ({
getPayload: () => undefined,
}),
};
const validator = createTokenValidator(
'a',
mockClient as unknown as OAuth2Client,
);
await expect(validator(validJwt)).rejects.toMatchObject({
name: 'TypeError',
message: 'Token had no payload',
});
});
});
describe('parseRequestToken', () => {
it('runs the happy path', async () => {
await expect(
parseRequestToken(
validJwt,
async () => ({ sub: 's', email: 'e@mail.com' } as any),
),
).resolves.toMatchObject({
iapToken: {
sub: 's',
email: 'e@mail.com',
},
});
});
it('rejects bad tokens', async () => {
await expect(
parseRequestToken(7, undefined as any),
).rejects.toMatchObject({
name: 'AuthenticationError',
message: 'Missing Google IAP header: x-goog-iap-jwt-assertion',
});
await expect(
parseRequestToken(undefined, undefined as any),
).rejects.toMatchObject({
name: 'AuthenticationError',
message: 'Missing Google IAP header: x-goog-iap-jwt-assertion',
});
await expect(
parseRequestToken('', undefined as any),
).rejects.toMatchObject({
name: 'AuthenticationError',
message: 'Missing Google IAP header: x-goog-iap-jwt-assertion',
});
});
it('translates validator errors', async () => {
await expect(
parseRequestToken(validJwt, async () => {
throw new ConflictError('Ouch');
}),
).rejects.toMatchObject({
name: 'AuthenticationError',
message: 'Google IAP token verification failed, ConflictError: Ouch',
});
});
it('rejects bad token payloads', async () => {
await expect(
parseRequestToken(validJwt, async () => ({ sub: 'a' } as any)),
).rejects.toMatchObject({
name: 'AuthenticationError',
message: 'Google IAP token payload is missing sub and/or email claim',
});
});
});
});
@@ -0,0 +1,84 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { AuthenticationError } from '@backstage/errors';
import { OAuth2Client, TokenPayload } from 'google-auth-library';
import { AuthHandler } from '../types';
import { GcpIapResult, IAP_JWT_HEADER } from './types';
export function createTokenValidator(
audience: string,
mockClient?: OAuth2Client,
): (token: string) => Promise<TokenPayload> {
const client = mockClient ?? new OAuth2Client();
return async function tokenValidator(token) {
// TODO(freben): Rate limit the public key reads. It may be sensible to
// cache these for some reasonable time rather than asking for the public
// keys on every single sign-in. But since the rate of events here is so
// slow, I decided to keep it simple for now.
const response = await client.getIapPublicKeys();
const ticket = await client.verifySignedJwtWithCertsAsync(
token,
response.pubkeys,
audience,
['https://cloud.google.com/iap'],
);
const payload = ticket.getPayload();
if (!payload) {
throw new TypeError('Token had no payload');
}
return payload;
};
}
export async function parseRequestToken(
jwtToken: unknown,
tokenValidator: (token: string) => Promise<TokenPayload>,
): Promise<GcpIapResult> {
if (typeof jwtToken !== 'string' || !jwtToken) {
throw new AuthenticationError(
`Missing Google IAP header: ${IAP_JWT_HEADER}`,
);
}
let payload: TokenPayload;
try {
payload = await tokenValidator(jwtToken);
} catch (e) {
throw new AuthenticationError(`Google IAP token verification failed, ${e}`);
}
if (!payload.sub || !payload.email) {
throw new AuthenticationError(
'Google IAP token payload is missing sub and/or email claim',
);
}
return {
iapToken: {
...payload,
sub: payload.sub,
email: payload.email,
},
};
}
export const defaultAuthHandler: AuthHandler<GcpIapResult> = async ({
iapToken,
}) => ({ profile: { email: iapToken.email } });
@@ -0,0 +1,22 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { createGcpIapProvider } from './provider';
export type {
GcpIapProviderOptions,
GcpIapResult,
GcpIapTokenInfo,
} from './types';
@@ -0,0 +1,76 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { GcpIapProvider } from './provider';
beforeEach(() => {
jest.clearAllMocks();
});
describe('GcpIapProvider', () => {
const authHandler = jest.fn();
const signInResolver = jest.fn();
const tokenValidator = jest.fn();
const logger = getVoidLogger();
it('runs the happy path', async () => {
const provider = new GcpIapProvider({
authHandler,
signInResolver,
tokenValidator,
tokenIssuer: {} as any,
catalogIdentityClient: {} as any,
logger,
});
// { "sub": "user:default/me", "ent": ["group:default/home"] }
const backstageToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvbWUiLCJlbnQiOlsiZ3JvdXA6ZGVmYXVsdC9ob21lIl19.CbmAKzFErGmtsnpRxyPc7dHv7WEjb5lY6206YCzR_Rc';
const iapToken = { sub: 's', email: 'e@mail.com' };
authHandler.mockResolvedValueOnce({ email: 'e@mail.com' });
signInResolver.mockResolvedValueOnce({ id: 'i', token: backstageToken });
tokenValidator.mockResolvedValueOnce(iapToken);
const app = express();
app.use('/refresh', provider.refresh.bind(provider));
const response = await request(app)
.get('/refresh')
.set('x-goog-iap-jwt-assertion', 'token');
expect(response.status).toBe(200);
expect(response.get('content-type')).toBe(
'application/json; charset=utf-8',
);
expect(response.body).toEqual({
backstageIdentity: {
id: 'i',
idToken: backstageToken,
token: backstageToken,
identity: {
type: 'user',
userEntityRef: 'user:default/me',
ownershipEntityRefs: ['group:default/home'],
},
},
providerInfo: { iapToken },
});
});
});
@@ -0,0 +1,125 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { TokenPayload } from 'google-auth-library';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
import { prepareBackstageIdentityResponse } from '../prepareBackstageIdentityResponse';
import {
AuthHandler,
AuthProviderFactory,
AuthProviderRouteHandlers,
SignInResolver,
} from '../types';
import {
createTokenValidator,
defaultAuthHandler,
parseRequestToken,
} from './helpers';
import {
GcpIapProviderOptions,
GcpIapResponse,
GcpIapResult,
IAP_JWT_HEADER,
} from './types';
export class GcpIapProvider implements AuthProviderRouteHandlers {
private readonly authHandler: AuthHandler<GcpIapResult>;
private readonly signInResolver: SignInResolver<GcpIapResult>;
private readonly tokenValidator: (token: string) => Promise<TokenPayload>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: {
authHandler: AuthHandler<GcpIapResult>;
signInResolver: SignInResolver<GcpIapResult>;
tokenValidator: (token: string) => Promise<TokenPayload>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
}) {
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this.tokenValidator = options.tokenValidator;
this.tokenIssuer = options.tokenIssuer;
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
}
async start() {}
async frameHandler() {}
async refresh(req: express.Request, res: express.Response): Promise<void> {
const result = await parseRequestToken(
req.header(IAP_JWT_HEADER),
this.tokenValidator,
);
const { profile } = await this.authHandler(result);
const backstageIdentity = await this.signInResolver(
{ profile, result },
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
const response: GcpIapResponse = {
providerInfo: { iapToken: result.iapToken },
profile,
backstageIdentity: prepareBackstageIdentityResponse(backstageIdentity),
};
res.json(response);
}
}
/**
* Creates an auth provider for Google Identity-Aware Proxy.
*
* @public
*/
export function createGcpIapProvider(
options: GcpIapProviderOptions,
): AuthProviderFactory {
return ({ config, tokenIssuer, catalogApi, logger }) => {
const audience = config.getString('audience');
const authHandler = options.authHandler ?? defaultAuthHandler;
const signInResolver = options.signIn.resolver;
const tokenValidator = createTokenValidator(audience);
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
return new GcpIapProvider({
authHandler,
signInResolver,
tokenValidator,
tokenIssuer,
catalogIdentityClient,
logger,
});
};
}
@@ -0,0 +1,96 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { JsonValue } from '@backstage/types';
import { AuthHandler, AuthResponse, SignInResolver } from '../types';
/**
* The header name used by the IAP.
*/
export const IAP_JWT_HEADER = 'x-goog-iap-jwt-assertion';
/**
* The data extracted from an IAP token.
*
* @public
*/
export type GcpIapTokenInfo = {
/**
* The unique, stable identifier for the user.
*/
sub: string;
/**
* User email address.
*/
email: string;
/**
* Other fields.
*/
[key: string]: JsonValue;
};
/**
* The result of the initial auth challenge. This is the input to the auth
* callbacks.
*
* @public
*/
export type GcpIapResult = {
/**
* The data extracted from the IAP token header.
*/
iapToken: GcpIapTokenInfo;
};
/**
* The provider info to return to the frontend.
*/
export type GcpIapProviderInfo = {
/**
* The data extracted from the IAP token header.
*/
iapToken: GcpIapTokenInfo;
};
/**
* The shape of the response to return to callers.
*/
export type GcpIapResponse = AuthResponse<GcpIapProviderInfo>;
/**
* Options for {@link createGcpIapProvider}.
*
* @public
*/
export type GcpIapProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth
* response into the profile that will be presented to the user. The default
* implementation just provides the authenticated email that the IAP
* presented.
*/
authHandler?: AuthHandler<GcpIapResult>;
/**
* Configures sign-in for this provider.
*/
signIn: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<GcpIapResult>;
};
};
@@ -316,24 +316,26 @@ describe('GithubAuthProvider', () => {
],
});
const response = await provider.refresh({} as any);
const result = await provider.refresh({} as any);
expect(response).toEqual({
backstageIdentity: {
id: 'mockuser',
token: 'token-for-mockuser',
},
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: undefined,
},
providerInfo: {
accessToken: 'a.b.c',
refreshToken: 'dont-forget-to-send-refresh',
expiresInSeconds: 123,
scope: 'read_user',
expect(result).toEqual({
response: {
backstageIdentity: {
id: 'mockuser',
token: 'token-for-mockuser',
},
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: undefined,
},
providerInfo: {
accessToken: 'a.b.c',
expiresInSeconds: 123,
scope: 'read_user',
},
},
refreshToken: 'dont-forget-to-send-refresh',
});
});
});
@@ -129,26 +129,26 @@ export class GithubAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const {
accessToken,
refreshToken: newRefreshToken,
params,
} = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: newRefreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: GithubOAuthResult) {
@@ -158,7 +158,6 @@ export class GithubAuthProvider implements OAuthHandlers {
const response: OAuthResponse = {
providerInfo: {
accessToken: result.accessToken,
refreshToken: result.refreshToken, // GitHub expires the old refresh token when used
scope: result.params.scope,
expiresInSeconds:
expiresInStr === undefined ? undefined : Number(expiresInStr),
@@ -223,7 +222,7 @@ export type GithubProviderOptions = {
* Providing your own stateEncoder will allow you to add addition parameters to the state field.
*
* It is typed as follows:
* export type StateEncoder = (input: OAuthState) => Promise<{encodedState: string}>;
* `export type StateEncoder = (input: OAuthState) => Promise<{encodedState: string}>;`
*
* Note: the stateEncoder must encode a 'nonce' value and an 'env' value. Without this, the OAuth flow will fail
* (These two values will be set by the req.state by default)
@@ -184,23 +184,25 @@ describe('GitlabAuthProvider', () => {
],
});
const response = await provider.refresh({} as any);
const result = await provider.refresh({} as any);
expect(response).toEqual({
backstageIdentity: {
id: 'mockuser',
},
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: 'http://gitlab.com/lols',
},
providerInfo: {
accessToken: 'a.b.c',
idToken: 'my-id',
refreshToken: 'dont-forget-to-send-refresh',
scope: 'read_user',
expect(result).toEqual({
response: {
backstageIdentity: {
id: 'mockuser',
},
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: 'http://gitlab.com/lols',
},
providerInfo: {
accessToken: 'a.b.c',
idToken: 'my-id',
scope: 'read_user',
},
},
refreshToken: 'dont-forget-to-send-refresh',
});
});
});
@@ -132,9 +132,7 @@ export class GitlabAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -146,28 +144,26 @@ export class GitlabAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const {
accessToken,
refreshToken: newRefreshToken,
params,
} = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: newRefreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: OAuthResult): Promise<OAuthResponse> {
@@ -177,7 +173,6 @@ export class GitlabAuthProvider implements OAuthHandlers {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
refreshToken: result.refreshToken, // GitLab expires the old refresh token when used
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
@@ -113,9 +113,7 @@ export class GoogleAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -127,22 +125,26 @@ export class GoogleAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: OAuthResult) {
+6 -3
View File
@@ -14,6 +14,10 @@
* limitations under the License.
*/
export * from './atlassian';
export * from './auth0';
export * from './aws-alb';
export * from './bitbucket';
export * from './github';
export * from './gitlab';
export * from './google';
@@ -21,10 +25,9 @@ export * from './microsoft';
export * from './oauth2';
export * from './oidc';
export * from './okta';
export * from './bitbucket';
export * from './atlassian';
export * from './aws-alb';
export * from './onelogin';
export * from './saml';
export * from './gcp-iap';
export { factories as defaultAuthProviderFactories } from './factories';
@@ -20,6 +20,9 @@ import { OAuthResult } from '../../lib/oauth';
import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
import { setupRequestMockHandlers } from '@backstage/test-utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
const mockFrameHandler = jest.spyOn(
helpers,
@@ -28,8 +31,62 @@ const mockFrameHandler = jest.spyOn(
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
const mockResult = {
result: {
fullProfile: {
emails: [
{
type: 'work',
value: 'conrad@example.com',
},
],
displayName: 'Conrad',
name: {
familyName: 'Ribas',
givenName: 'Francisco',
},
id: 'conrad',
provider: 'microsoft',
photos: [
{
value: 'some-data',
},
],
},
params: {
id_token: 'idToken',
scope: 'scope',
expires_in: 123,
},
accessToken: 'accessToken',
},
privateInfo: {
refreshToken: 'wacka',
},
};
const server = setupServer();
setupRequestMockHandlers(server);
const setupHandlers = () => {
server.use(
rest.get(
'https://graph.microsoft.com/v1.0/me/photos/*',
async (_, res, ctx) => {
const imageBuffer = new Uint8Array([104, 111, 119, 100, 121]).buffer;
return res(
ctx.set('Content-Length', imageBuffer.byteLength.toString()),
ctx.set('Content-Type', 'image/jpeg'),
ctx.body(imageBuffer),
);
},
),
);
};
describe('createMicrosoftProvider', () => {
it('should auth', async () => {
setupHandlers();
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
@@ -55,39 +112,7 @@ describe('createMicrosoftProvider', () => {
callbackUrl: 'mock',
});
mockFrameHandler.mockResolvedValueOnce({
result: {
fullProfile: {
emails: [
{
type: 'work',
value: 'conrad@example.com',
},
],
displayName: 'Conrad',
name: {
familyName: 'Ribas',
givenName: 'Francisco',
},
id: 'conrad',
provider: 'microsoft',
photos: [
{
value: 'some-data',
},
],
},
params: {
id_token: 'idToken',
scope: 'scope',
expires_in: 123,
},
accessToken: 'accessToken',
},
privateInfo: {
refreshToken: 'wacka',
},
});
mockFrameHandler.mockResolvedValueOnce(mockResult);
const { response } = await provider.handler({} as any);
expect(response).toEqual({
providerInfo: {
@@ -103,4 +128,45 @@ describe('createMicrosoftProvider', () => {
},
});
});
it('should return the base64 encoded photo data of the profile', async () => {
setupHandlers();
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new MicrosoftAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
displayName: fullProfile.displayName,
picture: 'http://microsoft.com/lols',
},
}),
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
// define resolver to return user `info` for photo validation
signInResolver: async (info, _) => {
return {
id: 'user.name',
token: 'token',
info: info,
};
},
});
mockFrameHandler.mockResolvedValueOnce(mockResult);
const { response } = await provider.handler({} as any);
const overloadedIdentity = response.backstageIdentity as any;
const photo = overloadedIdentity.info.result.fullProfile.photos[0];
expect(photo.value).toEqual('data:image/jpeg;base64,aG93ZHk=');
});
});
@@ -45,7 +45,7 @@ import {
SignInResolver,
} from '../types';
import { Logger } from 'winston';
import got from 'got';
import fetch from 'node-fetch';
type PrivateInfo = {
refreshToken: string;
@@ -104,9 +104,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -118,24 +116,27 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: OAuthResult) {
@@ -173,19 +174,17 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
private getUserPhoto(accessToken: string): Promise<string | undefined> {
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,
fetch('https://graph.microsoft.com/v1.0/me/photos/48x48/$value', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})
.then(response => response.arrayBuffer())
.then(arrayBuffer => {
const imageUrl = `data:image/jpeg;base64,${Buffer.from(
arrayBuffer,
).toString('base64')}`;
resolve(photoURL);
resolve(imageUrl);
})
.catch(error => {
this.logger.warn(
@@ -127,9 +127,7 @@ export class OAuth2AuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -141,29 +139,27 @@ export class OAuth2AuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
async refresh(req: OAuthRefreshRequest) {
const refreshTokenResponse = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const {
accessToken,
params,
refreshToken: updatedRefreshToken,
} = refreshTokenResponse;
const { accessToken, params, refreshToken } = refreshTokenResponse;
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: updatedRefreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: OAuthResult) {
@@ -175,7 +171,6 @@ export class OAuth2AuthProvider implements OAuthHandlers {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
refreshToken: result.refreshToken,
},
profile,
};
@@ -112,34 +112,31 @@ export class OidcAuthProvider implements OAuthHandlers {
return await executeRedirectStrategy(req, strategy, options);
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
async handler(req: express.Request) {
const { strategy } = await this.implementation;
const strategyResponse = await executeFrameHandlerStrategy<
const { result, privateInfo } = await executeFrameHandlerStrategy<
OidcAuthResult,
PrivateInfo
>(req, strategy);
const {
result: { userinfo, tokenset },
privateInfo,
} = strategyResponse;
const identityResponse = await this.handleResult({ tokenset, userinfo });
return {
response: identityResponse,
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
async refresh(req: OAuthRefreshRequest) {
const { client } = await this.implementation;
const tokenset = await client.refresh(req.refreshToken);
if (!tokenset.access_token) {
throw new Error('Refresh failed');
}
const profile = await client.userinfo(tokenset.access_token);
return this.handleResult({ tokenset, userinfo: profile });
const userinfo = await client.userinfo(tokenset.access_token);
return {
response: await this.handleResult({ tokenset, userinfo }),
refreshToken: tokenset.refresh_token,
};
}
private async setupStrategy(options: Options): Promise<OidcImpl> {
@@ -190,7 +187,6 @@ export class OidcAuthProvider implements OAuthHandlers {
providerInfo: {
idToken: result.tokenset.id_token,
accessToken: result.tokenset.access_token!,
refreshToken: result.tokenset.refresh_token,
scope: result.tokenset.scope!,
expiresInSeconds: result.tokenset.expires_in,
},
@@ -133,9 +133,7 @@ export class OktaAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -147,24 +145,27 @@ export class OktaAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: OAuthResult) {
@@ -36,7 +36,15 @@ import {
executeFetchUserProfileStrategy,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
RedirectInfo,
AuthProviderFactory,
AuthHandler,
SignInResolver,
} from '../types';
import { CatalogIdentityClient } from '../../lib/catalog';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
type PrivateInfo = {
refreshToken: string;
@@ -44,12 +52,27 @@ type PrivateInfo = {
export type Options = OAuthProviderOptions & {
issuer: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class OneLoginProvider implements OAuthHandlers {
private readonly _strategy: any;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: Options) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.tokenIssuer = options.tokenIssuer;
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this._strategy = new OneLoginStrategy(
{
issuer: options.issuer,
@@ -89,86 +112,144 @@ export class OneLoginProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
>(req, this._strategy);
const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
return {
response: await this.populateIdentity({
profile,
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
}),
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
const profile = makeProfileInfo(fullProfile, params.id_token);
return this.populateIdentity({
providerInfo: {
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
});
}),
refreshToken,
};
}
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
private async handleResult(result: OAuthResult) {
const { profile } = await this.authHandler(result);
if (!profile.email) {
throw new Error('OIDC profile contained no email');
const response: OAuthResponse = {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
}
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id, token: '' } };
return response;
}
}
export type OneLoginProviderOptions = {};
const defaultSignInResolver: SignInResolver<OAuthResult> = async info => {
const { profile } = info;
if (!profile.email) {
throw new Error('OIDC profile contained no email');
}
const id = profile.email.split('@')[0];
return { id, token: '' };
};
/** @public */
export type OneLoginProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<OAuthResult>;
};
};
/** @public */
export const createOneLoginProvider = (
_options?: OneLoginProviderOptions,
options?: OneLoginProviderOptions,
): AuthProviderFactory => {
return ({ providerId, globalConfig, config, tokenIssuer }) =>
return ({
providerId,
globalConfig,
config,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const issuer = envConfig.getString('issuer');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolver = options?.signIn?.resolver ?? defaultSignInResolver;
const provider = new OneLoginProvider({
clientId,
clientSecret,
callbackUrl,
issuer,
authHandler,
signInResolver,
tokenIssuer,
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -22,7 +22,9 @@ function parseJwtPayload(token: string) {
}
/**
* Parses token and decorates the BackstageIdentityResponse with identity information sourced from the token
* Parses a Backstage-issued token and decorates the
* {@link BackstageIdentityResponse} with identity information sourced from the
* token.
*
* @public
*/
+35 -34
View File
@@ -59,10 +59,10 @@ export type RedirectInfo = {
*
* The routes in the auth backend API are tied to these methods like below
*
* /auth/[provider]/start -> start
* /auth/[provider]/handler/frame -> frameHandler
* /auth/[provider]/refresh -> refresh
* /auth/[provider]/logout -> logout
* `/auth/[provider]/start -> start`
* `/auth/[provider]/handler/frame -> frameHandler`
* `/auth/[provider]/refresh -> refresh`
* `/auth/[provider]/logout -> logout`
*/
export interface AuthProviderRouteHandlers {
/**
@@ -73,9 +73,6 @@ export interface AuthProviderRouteHandlers {
* Response
* - redirect to the auth provider for the user to sign in or consent.
* - sets a nonce cookie and also pass the nonce as 'state' query parameter in the redirect request
*
* @param {express.Request} req
* @param {express.Response} res
*/
start(req: express.Request, res: express.Response): Promise<void>;
@@ -88,9 +85,6 @@ export interface AuthProviderRouteHandlers {
* Response
* - postMessage to the window with a payload that contains accessToken, expiryInSeconds?, idToken? and scope.
* - sets a refresh token cookie if the auth provider supports refresh tokens
*
* @param {express.Request} req
* @param {express.Response} res
*/
frameHandler(req: express.Request, res: express.Response): Promise<void>;
@@ -102,9 +96,6 @@ export interface AuthProviderRouteHandlers {
* - to contain a refresh token cookie and scope (Optional) query parameter.
* Response
* - payload with accessToken, expiryInSeconds?, idToken?, scope and user profile information.
*
* @param {express.Request} req
* @param {express.Response} res
*/
refresh?(req: express.Request, res: express.Response): Promise<void>;
@@ -113,9 +104,6 @@ export interface AuthProviderRouteHandlers {
*
* Response
* - removes the refresh token cookie
*
* @param {express.Request} req
* @param {express.Response} res
*/
logout?(req: express.Request, res: express.Response): Promise<void>;
}
@@ -200,13 +188,16 @@ export interface BackstageSignInResult {
/**
* The old exported symbol for {@link BackstageSignInResult}.
*
* @public
* @deprecated Use the `BackstageSignInResult` type instead.
* @deprecated Use the {@link BackstageSignInResult} instead.
*/
export type BackstageIdentity = BackstageSignInResult;
/**
* Response object containing the {@link BackstageUserIdentity} and the token from the authentication provider.
* Response object containing the {@link BackstageUserIdentity} and the token
* from the authentication provider.
*
* @public
*/
export interface BackstageIdentityResponse extends BackstageSignInResult {
@@ -220,7 +211,8 @@ export interface BackstageIdentityResponse extends BackstageSignInResult {
* Used to display login information to user, i.e. sidebar popup.
*
* It is also temporarily used as the profile of the signed-in user's Backstage
* identity, but we want to replace that with data from identity and/org catalog service
* identity, but we want to replace that with data from identity and/org catalog
* service
*
* @public
*/
@@ -241,28 +233,32 @@ export type ProfileInfo = {
};
/**
* type of sign in information context, includes the profile information and authentication result which contains auth. related information
* Type of sign in information context. Includes the profile information and
* authentication result which contains auth related information.
*
* @public
*/
export type SignInInfo<AuthResult> = {
export type SignInInfo<TAuthResult> = {
/**
* The simple profile passed down for use in the frontend.
*/
profile: ProfileInfo;
/**
* The authentication result that was received from the authentication provider.
* The authentication result that was received from the authentication
* provider.
*/
result: AuthResult;
result: TAuthResult;
};
/**
* Sign in resolver type describes the function which handles the result of a successful authentication
* and it must return a valid {@link BackstageSignInResult}
* Describes the function which handles the result of a successful
* authentication. Must return a valid {@link BackstageSignInResult}.
*
* @public
*/
export type SignInResolver<AuthResult> = (
info: SignInInfo<AuthResult>,
export type SignInResolver<TAuthResult> = (
info: SignInInfo<TAuthResult>,
context: {
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
@@ -271,23 +267,28 @@ export type SignInResolver<AuthResult> = (
) => Promise<BackstageSignInResult>;
/**
* The return type of authentication handler which must contain a valid profile information
* The return type of an authentication handler. Must contain valid profile
* information.
*
* @public
*/
export type AuthHandlerResult = { profile: ProfileInfo };
/**
* The AuthHandler function is called every time the user authenticates using the provider.
* The AuthHandler function is called every time the user authenticates using
* the provider.
*
* The handler should return a profile that represents the session for the user in the frontend.
* The handler should return a profile that represents the session for the user
* in the frontend.
*
* Throwing an error in the function will cause the authentication to fail, making it
* possible to use this function as a way to limit access to a certain group of users.
* Throwing an error in the function will cause the authentication to fail,
* making it possible to use this function as a way to limit access to a certain
* group of users.
*
* @public
*/
export type AuthHandler<AuthResult> = (
input: AuthResult,
export type AuthHandler<TAuthResult> = (
input: TAuthResult,
) => Promise<AuthHandlerResult>;
export type StateEncoder = (
+9 -1
View File
@@ -68,7 +68,15 @@ export async function createRouter(
if (secret) {
router.use(cookieParser(secret));
// TODO: Configure the server-side session storage. The default MemoryStore is not designed for production
router.use(session({ secret, saveUninitialized: false, resave: false }));
const enforceCookieSSL = authUrl.startsWith('https');
router.use(
session({
secret,
saveUninitialized: false,
resave: false,
cookie: { secure: enforceCookieSSL ? 'auto' : false },
}),
);
router.use(passport.initialize());
router.use(passport.session());
} else {
+68
View File
@@ -1,5 +1,73 @@
# @backstage/plugin-azure-devops-backend
## 0.2.6
### Patch Changes
- Updated dependencies
- @backstage/backend-common@0.10.0
## 0.2.5
### Patch Changes
- daf32e2c9b: Created some initial filters that can be used to create pull request columns:
- All
- AssignedToUser
- AssignedToCurrentUser
- AssignedToTeam
- AssignedToTeams
- AssignedToCurrentUsersTeams
- CreatedByUser
- CreatedByCurrentUser
- CreatedByTeam
- CreatedByTeams
- CreatedByCurrentUsersTeams
Example custom column creation:
```tsx
const COLUMN_CONFIGS: PullRequestColumnConfig[] = [
{
title: 'Created by me',
filters: [{ type: FilterType.CreatedByCurrentUser }],
},
{
title: 'Created by Backstage Core',
filters: [
{
type: FilterType.CreatedByTeam,
teamName: 'Backstage Core',
},
],
},
{
title: 'Assigned to my teams',
filters: [{ type: FilterType.AssignedToCurrentUsersTeams }],
},
{
title: 'Other PRs',
filters: [{ type: FilterType.All }],
simplified: true,
},
];
<Route
path="/azure-pull-requests"
element={
<AzurePullRequestsPage
projectName="{PROJECT_NAME}"
defaultColumnConfigs={COLUMN_CONFIGS}
/>
}
/>;
```
- Updated dependencies
- @backstage/backend-common@0.9.14
- @backstage/plugin-azure-devops-common@0.1.3
## 0.2.4
### Patch Changes
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-azure-devops-backend",
"version": "0.2.4",
"version": "0.2.6",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,9 +20,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.13",
"@backstage/backend-common": "^0.10.0",
"@backstage/config": "^0.1.11",
"@backstage/plugin-azure-devops-common": "^0.1.2",
"@backstage/plugin-azure-devops-common": "^0.1.3",
"@types/express": "^4.17.6",
"azure-devops-node-api": "^11.0.1",
"express": "^4.17.1",
@@ -31,9 +31,9 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/cli": "^0.10.3",
"@types/supertest": "^2.0.8",
"supertest": "^4.0.2",
"supertest": "^6.1.6",
"msw": "^0.35.0"
},
"files": [
@@ -5,7 +5,7 @@
* 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
* 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,
+57
View File
@@ -1,5 +1,62 @@
# @backstage/plugin-azure-devops-common
## 0.1.3
### Patch Changes
- daf32e2c9b: Created some initial filters that can be used to create pull request columns:
- All
- AssignedToUser
- AssignedToCurrentUser
- AssignedToTeam
- AssignedToTeams
- AssignedToCurrentUsersTeams
- CreatedByUser
- CreatedByCurrentUser
- CreatedByTeam
- CreatedByTeams
- CreatedByCurrentUsersTeams
Example custom column creation:
```tsx
const COLUMN_CONFIGS: PullRequestColumnConfig[] = [
{
title: 'Created by me',
filters: [{ type: FilterType.CreatedByCurrentUser }],
},
{
title: 'Created by Backstage Core',
filters: [
{
type: FilterType.CreatedByTeam,
teamName: 'Backstage Core',
},
],
},
{
title: 'Assigned to my teams',
filters: [{ type: FilterType.AssignedToCurrentUsersTeams }],
},
{
title: 'Other PRs',
filters: [{ type: FilterType.All }],
simplified: true,
},
];
<Route
path="/azure-pull-requests"
element={
<AzurePullRequestsPage
projectName="{PROJECT_NAME}"
defaultColumnConfigs={COLUMN_CONFIGS}
/>
}
/>;
```
## 0.1.2
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-azure-devops-common",
"version": "0.1.2",
"version": "0.1.3",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -29,7 +29,7 @@
"clean": "backstage-cli clean"
},
"devDependencies": {
"@backstage/cli": "^0.10.1"
"@backstage/cli": "^0.10.2"
},
"files": [
"dist"
+83
View File
@@ -1,5 +1,88 @@
# @backstage/plugin-azure-devops
## 0.1.9
### Patch Changes
- 4ce51ab0f1: Internal refactor of the `react-use` imports to use `react-use/lib/*` instead.
- Updated dependencies
- @backstage/core-plugin-api@0.4.1
- @backstage/plugin-catalog-react@0.6.10
- @backstage/core-components@0.8.3
## 0.1.8
### Patch Changes
- Updated dependencies
- @backstage/core-plugin-api@0.4.0
- @backstage/plugin-catalog-react@0.6.8
- @backstage/core-components@0.8.2
## 0.1.7
### Patch Changes
- daf32e2c9b: Created some initial filters that can be used to create pull request columns:
- All
- AssignedToUser
- AssignedToCurrentUser
- AssignedToTeam
- AssignedToTeams
- AssignedToCurrentUsersTeams
- CreatedByUser
- CreatedByCurrentUser
- CreatedByTeam
- CreatedByTeams
- CreatedByCurrentUsersTeams
Example custom column creation:
```tsx
const COLUMN_CONFIGS: PullRequestColumnConfig[] = [
{
title: 'Created by me',
filters: [{ type: FilterType.CreatedByCurrentUser }],
},
{
title: 'Created by Backstage Core',
filters: [
{
type: FilterType.CreatedByTeam,
teamName: 'Backstage Core',
},
],
},
{
title: 'Assigned to my teams',
filters: [{ type: FilterType.AssignedToCurrentUsersTeams }],
},
{
title: 'Other PRs',
filters: [{ type: FilterType.All }],
simplified: true,
},
];
<Route
path="/azure-pull-requests"
element={
<AzurePullRequestsPage
projectName="{PROJECT_NAME}"
defaultColumnConfigs={COLUMN_CONFIGS}
/>
}
/>;
```
- Updated dependencies
- @backstage/core-plugin-api@0.3.1
- @backstage/core-components@0.8.1
- @backstage/plugin-azure-devops-common@0.1.3
- @backstage/catalog-model@0.9.8
- @backstage/plugin-catalog-react@0.6.7
## 0.1.6
### Patch Changes
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-azure-devops",
"version": "0.1.6",
"version": "0.1.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -27,12 +27,12 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.9.7",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/catalog-model": "^0.9.8",
"@backstage/core-components": "^0.8.3",
"@backstage/core-plugin-api": "^0.4.1",
"@backstage/errors": "^0.1.4",
"@backstage/plugin-azure-devops-common": "^0.1.2",
"@backstage/plugin-catalog-react": "^0.6.5",
"@backstage/plugin-azure-devops-common": "^0.1.3",
"@backstage/plugin-catalog-react": "^0.6.10",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
@@ -46,10 +46,10 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@backstage/cli": "^0.10.5",
"@backstage/core-app-api": "^0.3.1",
"@backstage/dev-utils": "^0.2.16",
"@backstage/test-utils": "^0.2.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
@@ -27,8 +27,6 @@ import { createApiRef } from '@backstage/core-plugin-api';
export const azureDevOpsApiRef = createApiRef<AzureDevOpsApi>({
id: 'plugin.azure-devops.service',
description:
'Used by the Azure DevOps plugin to make requests to accompanying backend',
});
export interface AzureDevOpsApi {
@@ -28,7 +28,7 @@ import {
/**
* Filters a reviewer based on vote status and if the reviewer is required.
* @param reviewer a reviewer to filter.
* @param reviewer - a reviewer to filter.
* @returns whether or not to filter the `reviewer`.
*/
export function reviewerFilter(reviewer: Reviewer): boolean {
@@ -39,8 +39,8 @@ export function reviewerFilter(reviewer: Reviewer): boolean {
/**
* Removes values from the provided array and returns them.
* @param arr the array to extract values from.
* @param filter a filter used to extract values from the provided array.
* @param arr - the array to extract values from.
* @param filter - a filter used to extract values from the provided array.
* @returns the values that were extracted from the array.
*
* @example
@@ -80,8 +80,8 @@ export function arrayExtract<T>(arr: T[], filter: (value: T) => unknown): T[] {
/**
* Creates groups of pull requests based on a list of `PullRequestGroupConfig`.
* @param pullRequests all pull requests to be split up into groups.
* @param configs the config used for splitting up the pull request groups.
* @param pullRequests - all pull requests to be split up into groups.
* @param configs - the config used for splitting up the pull request groups.
* @returns a list of pull request groups.
*/
export function getPullRequestGroups(
@@ -17,7 +17,7 @@
import { Team } from '@backstage/plugin-azure-devops-common';
import { azureDevOpsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import { useAsync } from 'react-use';
import useAsync from 'react-use/lib/useAsync';
export function useAllTeams(): {
teams?: Team[];
@@ -15,7 +15,8 @@
*/
import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { useAsyncRetry, useInterval } from 'react-use';
import useAsyncRetry from 'react-use/lib/useAsyncRetry';
import useInterval from 'react-use/lib/useInterval';
import { DashboardPullRequest } from '@backstage/plugin-azure-devops-common';
import { azureDevOpsApiRef } from '../api';
@@ -24,7 +24,7 @@ import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants';
import { Entity } from '@backstage/catalog-model';
import { azureDevOpsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import { useAsync } from 'react-use';
import useAsync from 'react-use/lib/useAsync';
import { useProjectRepoFromEntity } from './useProjectRepoFromEntity';
export function usePullRequests(
@@ -23,7 +23,7 @@ import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants';
import { Entity } from '@backstage/catalog-model';
import { azureDevOpsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import { useAsync } from 'react-use';
import useAsync from 'react-use/lib/useAsync';
import { useProjectRepoFromEntity } from './useProjectRepoFromEntity';
export function useRepoBuilds(
+8
View File
@@ -1,5 +1,13 @@
# @backstage/plugin-badges-backend
## 0.1.14
### Patch Changes
- Updated dependencies
- @backstage/backend-common@0.10.0
- @backstage/catalog-client@0.5.3
## 0.1.13
### Patch Changes
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-badges-backend",
"description": "A Backstage backend plugin that generates README badges for your entities",
"version": "0.1.13",
"version": "0.1.14",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -31,8 +31,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.12",
"@backstage/catalog-client": "^0.5.2",
"@backstage/backend-common": "^0.10.0",
"@backstage/catalog-client": "^0.5.3",
"@backstage/catalog-model": "^0.9.7",
"@backstage/config": "^0.1.10",
"@backstage/errors": "^0.1.5",
@@ -45,7 +45,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.0",
"@backstage/cli": "^0.10.3",
"@types/supertest": "^2.0.8",
"supertest": "^6.1.3"
},
+19
View File
@@ -1,5 +1,24 @@
# @backstage/plugin-badges
## 0.2.18
### Patch Changes
- 4ce51ab0f1: Internal refactor of the `react-use` imports to use `react-use/lib/*` instead.
- Updated dependencies
- @backstage/core-plugin-api@0.4.1
- @backstage/plugin-catalog-react@0.6.10
- @backstage/core-components@0.8.3
## 0.2.17
### Patch Changes
- Updated dependencies
- @backstage/core-plugin-api@0.4.0
- @backstage/plugin-catalog-react@0.6.8
- @backstage/core-components@0.8.2
## 0.2.16
### Patch Changes
+8 -8
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-badges",
"description": "A Backstage plugin that generates README badges for your entities",
"version": "0.2.16",
"version": "0.2.18",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -28,10 +28,10 @@
},
"dependencies": {
"@backstage/catalog-model": "^0.9.7",
"@backstage/core-components": "^0.8.0",
"@backstage/core-plugin-api": "^0.3.0",
"@backstage/core-components": "^0.8.3",
"@backstage/core-plugin-api": "^0.4.1",
"@backstage/errors": "^0.1.5",
"@backstage/plugin-catalog-react": "^0.6.5",
"@backstage/plugin-catalog-react": "^0.6.10",
"@backstage/theme": "^0.2.14",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
@@ -43,10 +43,10 @@
"react": "^16.13.1 || ^17.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1",
"@backstage/core-app-api": "^0.2.0",
"@backstage/dev-utils": "^0.2.14",
"@backstage/test-utils": "^0.1.24",
"@backstage/cli": "^0.10.5",
"@backstage/core-app-api": "^0.3.1",
"@backstage/dev-utils": "^0.2.16",
"@backstage/test-utils": "^0.2.1",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
-1
View File
@@ -19,7 +19,6 @@ import { createApiRef } from '@backstage/core-plugin-api';
export const badgesApiRef = createApiRef<BadgesApi>({
id: 'plugin.badges.client',
description: 'Used to make requests to the badges backend',
});
export type BadgeStyle =
@@ -27,7 +27,7 @@ import {
useTheme,
} from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import useAsync from 'react-use/lib/useAsync';
import { badgesApiRef } from '../api';
import {
+9
View File
@@ -1,5 +1,14 @@
# @backstage/plugin-bazaar-backend
## 0.1.5
### Patch Changes
- 26926bb7a7: made the linkage between a Bazaar project to a catalog Entity optional
- Updated dependencies
- @backstage/backend-common@0.10.0
- @backstage/backend-test-utils@0.1.11
## 0.1.4
### Patch Changes
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-bazaar-backend",
"version": "0.1.4",
"version": "0.1.5",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,8 +20,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.13",
"@backstage/backend-test-utils": "^0.1.10",
"@backstage/backend-common": "^0.10.0",
"@backstage/backend-test-utils": "^0.1.11",
"@backstage/config": "^0.1.5",
"@types/express": "^4.17.6",
"express": "^4.17.1",
@@ -31,7 +31,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.10.1"
"@backstage/cli": "^0.10.3"
},
"files": [
"dist",
@@ -5,7 +5,7 @@
* 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
* 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,

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