Merge branch 'master' of https://github.com/backstage/backstage into bazaar-workflow-changes
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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();
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './ApiExplorerPage';
|
||||
export * from './ApiDefinitionCard';
|
||||
export * from './ApisCards';
|
||||
export * from './AsyncApiDefinitionWidget';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user