Merge branch 'master' into k8s-plugin

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-09-29 17:08:26 +02:00
1998 changed files with 67759 additions and 24890 deletions
+29
View File
@@ -0,0 +1,29 @@
# @backstage/plugin-allure
## 0.1.3
### Patch Changes
- Updated dependencies
- @backstage/core-components@0.5.0
- @backstage/plugin-catalog-react@0.5.0
- @backstage/catalog-model@0.9.3
## 0.1.2
### Patch Changes
- 9f1362dcc1: Upgrade `@material-ui/lab` to `4.0.0-alpha.57`.
- Updated dependencies
- @backstage/core-components@0.4.2
- @backstage/plugin-catalog-react@0.4.6
- @backstage/core-plugin-api@0.1.8
## 0.1.1
### Patch Changes
- Updated dependencies
- @backstage/plugin-catalog-react@0.4.5
- @backstage/core-components@0.4.0
- @backstage/catalog-model@0.9.1
+47
View File
@@ -0,0 +1,47 @@
# [Allure](https://docs.qameta.io/allure/)
Welcome to the Backstage Allure plugin. This plugin add an entity service page to display Allure test reports related to the service.
## Install
Run the below command from the `app` package directory.
```shell
yarn add @backstage/plugin-allure
```
alternatively, you can execute below command from the root directory of your Backstage app.
```shell
yarn workspace app add @backstage/plugin-allure
```
## Configure
### Configure Allure service
Add below configuration in the `app-config.yaml`.
```yaml
allure:
baseUrl: <ALLURE_SERVICE_BASE_URL> # Example: https://allure.my-company.net or when running allure locally, http://localhost:5050/allure-docker-service
```
### Setup entity service page
Add `EntityAllureReportContent` in the `EntityPage.tsx` like below:
```diff
+ import { EntityAllureReportContent } from '@backstage/plugin-allure';
...
const serviceEntityPage = (
<EntityLayoutWrapper>
...
+ <EntityLayout.Route path="/allure" title="Allure Report">
+ <EntityAllureReportContent />
+ </EntityLayout.Route>
</EntityLayoutWrapper>
);
```
+25
View File
@@ -0,0 +1,25 @@
## API Report File for "@backstage/plugin-allure"
> 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) "allurePlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const allurePlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{}
>;
// Warning: (ae-missing-release-tag) "EntityAllureReportContent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const EntityAllureReportContent: () => JSX.Element;
```
+11
View File
@@ -0,0 +1,11 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: allure-example-service
description: Allure Example Service
annotations:
qameta.io/allure-project: sample
spec:
type: service
lifecycle: experimental
owner: team-a
+55
View File
@@ -0,0 +1,55 @@
/*
* 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 { EntityAllureReportContent } from '../src/plugin';
import { Content, Header, Page } from '@backstage/core-components';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { Entity } from '@backstage/catalog-model';
import exampleEntity from './example-entity.yaml';
import { allureApiRef } from '../src/api';
createDevApp()
.registerApi({
api: allureApiRef,
deps: {},
factory: () =>
({
async getReportUrl(projectId: string) {
return Promise.resolve(
// Follow the instructions from https://github.com/fescobar/allure-docker-service-ui
// to setup Allure service locally
`http://localhost:5050/allure-docker-service/projects/${projectId}/reports/latest/index.html`,
);
},
} as unknown as typeof allureApiRef.T),
})
.addPage({
element: (
<Page themeId="home">
<Header title="Allure Report" />
<Content>
<EntityProvider entity={exampleEntity as any as Entity}>
<EntityAllureReportContent />
</EntityProvider>
</Content>
</Page>
),
title: 'Allure Report',
path: '/allure',
})
.render();
+70
View File
@@ -0,0 +1,70 @@
{
"name": "@backstage/plugin-allure",
"description": "A Backstage plugin that integrates with Allure",
"version": "0.1.3",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.9.3",
"@backstage/core-components": "^0.5.0",
"@backstage/core-plugin-api": "^0.1.8",
"@backstage/plugin-catalog-react": "^0.5.0",
"@backstage/theme": "^0.2.10",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.7.13",
"@backstage/core-app-api": "^0.1.14",
"@backstage/dev-utils": "^0.2.10",
"@backstage/test-utils": "^0.1.17",
"@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",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0"
},
"files": [
"dist"
],
"configSchema": {
"$schema": "https://backstage.io/schema/config-v1",
"title": "@backstage/allure",
"type": "object",
"properties": {
"allure": {
"type": "object",
"properties": {
"baseUrl": {
"type": "string",
"visibility": "frontend"
}
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
/*
* 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 { createApiRef } from '@backstage/core-plugin-api';
export type AllureApi = {
getReportUrl(projectId: string): Promise<string>;
};
export const allureApiRef = createApiRef<AllureApi>({
id: 'allure-api',
});
+30
View File
@@ -0,0 +1,30 @@
/*
* 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 { ConfigApi } from '@backstage/core-plugin-api';
import { AllureApi } from './AllureApi';
export class AllureApiClient implements AllureApi {
readonly configApi: ConfigApi;
constructor(options: { configApi: ConfigApi }) {
this.configApi = options.configApi;
}
async getReportUrl(projectId: string): Promise<string> {
const baseUrl = this.configApi.getString('allure.baseUrl');
return `${baseUrl}/projects/${projectId}/reports/latest/index.html`;
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { allureApiRef } from './AllureApi';
export { AllureApiClient } from './AllureApiClient';
@@ -0,0 +1,44 @@
/*
* 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 { AllureReportComponent } from './AllureReportComponent';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { msw, renderInTestApp } from '@backstage/test-utils';
describe('ExampleComponent', () => {
const server = setupServer();
// Enable sane handlers for network requests
msw.setupDefaultHandlers(server);
// setup mock response
beforeEach(() => {
server.use(
rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({}))),
);
});
it('should render', async () => {
const rendered = await renderInTestApp(
<ThemeProvider theme={lightTheme}>
<AllureReportComponent />
</ThemeProvider>,
);
expect(rendered.getByText('Missing Annotation')).toBeInTheDocument();
});
});
@@ -0,0 +1,65 @@
/*
* 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 { useApi } from '@backstage/core-plugin-api';
import { allureApiRef } from '../../api';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
ALLURE_PROJECT_ID_ANNOTATION,
isAllureReportAvailable,
getAllureProjectId,
} from '../annotationHelpers';
import {
MissingAnnotationEmptyState,
Progress,
} from '@backstage/core-components';
import { useAsync } from 'react-use';
import { Entity } from '@backstage/catalog-model';
const AllureReport = (props: { entity: Entity }) => {
const allureApi = useApi(allureApiRef);
const allureProjectId = getAllureProjectId(props.entity);
const { value, loading } = useAsync(async () => {
const url = await allureApi.getReportUrl(allureProjectId);
return url;
});
if (loading) {
return <Progress />;
}
return (
<iframe
style={{
display: 'table',
width: '100%',
height: '100%',
}}
title="Allure Report"
src={value}
/>
);
};
export const AllureReportComponent = () => {
const { entity } = useEntity();
const isReportAvailable = entity && isAllureReportAvailable(entity);
if (isReportAvailable) return <AllureReport entity={entity} />;
return (
<MissingAnnotationEmptyState annotation={ALLURE_PROJECT_ID_ANNOTATION} />
);
};
@@ -13,5 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { CreateComponentButton } from './CreateComponentButton';
export { AllureReportComponent } from './AllureReportComponent';
@@ -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,22 +14,12 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import React, { ReactNode } from 'react';
import { EntityContext } from '../../hooks';
type EntityProviderProps = {
entity: Entity;
children: ReactNode;
export const ALLURE_PROJECT_ID_ANNOTATION = 'qameta.io/allure-project';
export const isAllureReportAvailable = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[ALLURE_PROJECT_ID_ANNOTATION]);
export const getAllureProjectId = (entity: Entity) => {
return entity?.metadata.annotations?.[ALLURE_PROJECT_ID_ANNOTATION] ?? '';
};
export const EntityProvider = ({ entity, children }: EntityProviderProps) => (
<EntityContext.Provider
value={{
entity,
loading: !Boolean(entity),
error: undefined,
}}
>
{children}
</EntityContext.Provider>
);
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A Backstage plugin that integrates with Allure
*
* @packageDocumentation
*/
export { allurePlugin, EntityAllureReportContent } from './plugin';
@@ -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.
@@ -13,11 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { allurePlugin } from './plugin';
import { registerComponentPlugin } from './plugin';
describe('register-component', () => {
describe('allure', () => {
it('should export plugin', () => {
expect(registerComponentPlugin).toBeDefined();
expect(allurePlugin).toBeDefined();
});
});
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
configApiRef,
createApiFactory,
createPlugin,
createRoutableExtension,
createRouteRef,
} from '@backstage/core-plugin-api';
import { AllureApiClient, allureApiRef } from './api';
export const allureRouteRef = createRouteRef({
title: 'allure-report',
});
export const allurePlugin = createPlugin({
id: 'allure',
apis: [
createApiFactory({
api: allureApiRef,
deps: {
configApi: configApiRef,
},
factory: ({ configApi }) => new AllureApiClient({ configApi }),
}),
],
routes: {
root: allureRouteRef,
},
});
export const EntityAllureReportContent = allurePlugin.provide(
createRoutableExtension({
component: () =>
import('./components/AllureReportComponent').then(
m => m.AllureReportComponent,
),
mountPoint: allureRouteRef,
}),
);
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
+83
View File
@@ -1,5 +1,88 @@
# @backstage/plugin-api-docs
## 0.6.9
### Patch Changes
- cc464a56b3: This makes Type and Lifecycle columns consistent for all table cases and adds a new line in Description column for better readability
- Updated dependencies
- @backstage/core-components@0.5.0
- @backstage/plugin-catalog@0.6.16
- @backstage/plugin-catalog-react@0.5.0
- @backstage/catalog-model@0.9.3
## 0.6.8
### Patch Changes
- 9f1362dcc1: Upgrade `@material-ui/lab` to `4.0.0-alpha.57`.
- Updated dependencies
- @backstage/core-components@0.4.2
- @backstage/plugin-catalog@0.6.15
- @backstage/plugin-catalog-react@0.4.6
- @backstage/core-plugin-api@0.1.8
## 0.6.7
### Patch Changes
- Updated dependencies
- @backstage/plugin-catalog-react@0.4.5
- @backstage/core-components@0.4.0
- @backstage/plugin-catalog@0.6.14
- @backstage/catalog-model@0.9.1
## 0.6.6
### Patch Changes
- 56c773909: Switched `@types/react` dependency to request `*` rather than a specific version.
- 0383314c9: Support deep linking in OpenAPI definitions.
- Updated dependencies
- @backstage/core-components@0.3.1
- @backstage/core-plugin-api@0.1.6
- @backstage/plugin-catalog@0.6.11
- @backstage/plugin-catalog-react@0.4.2
## 0.6.5
### Patch Changes
- 7b8aa8d0d: Move the `CreateComponentButton` from the catalog plugin to the `core-components` & rename it to `CreateButton` to be reused inside the api-docs plugin & scaffolder plugin, but also future plugins. Additionally, improve responsiveness of `CreateButton` & `SupportButton` by shrinking them to `IconButtons` on smaller screens.
- Updated dependencies
- @backstage/plugin-catalog@0.6.10
- @backstage/core-components@0.3.0
- @backstage/core-plugin-api@0.1.5
- @backstage/plugin-catalog-react@0.4.1
## 0.6.4
### Patch Changes
- 9d40fcb1e: - Bumping `material-ui/core` version to at least `4.12.2` as they made some breaking changes in later versions which broke `Pagination` of the `Table`.
- Switching out `material-table` to `@material-table/core` for support for the later versions of `material-ui/core`
- This causes a minor API change to `@backstage/core-components` as the interface for `Table` re-exports the `prop` from the underlying `Table` components.
- `onChangeRowsPerPage` has been renamed to `onRowsPerPageChange`
- `onChangePage` has been renamed to `onPageChange`
- Migration guide is here: https://material-table-core.com/docs/breaking-changes
- bebc09fa8: Add explicit import for `isomorphic-form-data` needed for `swagger-ui-react`
- Updated dependencies
- @backstage/core-components@0.2.0
- @backstage/plugin-catalog@0.6.9
- @backstage/plugin-catalog-react@0.4.0
- @backstage/core-plugin-api@0.1.4
- @backstage/theme@0.2.9
## 0.6.3
### Patch Changes
- 45b5fc3a8: Updated the layout of catalog and API index pages to handle smaller screen sizes. This adds responsive wrappers to the entity tables, and switches filters to a drawer when width-constrained. If you have created a custom catalog or API index page, you will need to update the page structure to match the updated [catalog customization](https://backstage.io/docs/features/software-catalog/catalog-customization) documentation.
- Updated dependencies
- @backstage/core-components@0.1.6
- @backstage/plugin-catalog@0.6.8
- @backstage/plugin-catalog-react@0.3.1
## 0.6.2
### Patch Changes
-2
View File
@@ -183,6 +183,4 @@ export const ProvidedApisCard: ({ variant }: Props_4) => JSX.Element;
//
// @public (undocumented)
export const ProvidingComponentsCard: ({ variant }: Props_7) => JSX.Element;
// (No @packageDocumentation comment for this package)
```
+8 -8
View File
@@ -30,19 +30,19 @@ import openapiApiEntity from './openapi-example-api.yaml';
import otherApiEntity from './other-example-api.yaml';
import { Content, Header, Page } from '@backstage/core-components';
const mockEntities = ([
const mockEntities = [
openapiApiEntity,
asyncapiApiEntity,
graphqlApiEntity,
otherApiEntity,
] as unknown) as Entity[];
] as unknown as Entity[];
createDevApp()
.registerApi({
api: catalogApiRef,
deps: {},
factory: () =>
(({
({
async getEntities() {
return {
items: mockEntities.slice(),
@@ -51,7 +51,7 @@ createDevApp()
async getEntityByName(name: string) {
return mockEntities.find(e => e.metadata.name === name);
},
} as unknown) as typeof catalogApiRef.T),
} as unknown as typeof catalogApiRef.T),
})
.registerApi({
api: apiDocsConfigRef,
@@ -72,7 +72,7 @@ createDevApp()
<Page themeId="home">
<Header title="OpenAPI" />
<Content>
<EntityProvider entity={(openapiApiEntity as any) as Entity}>
<EntityProvider entity={openapiApiEntity as any as Entity}>
<EntityApiDefinitionCard />
</EntityProvider>
</Content>
@@ -85,7 +85,7 @@ createDevApp()
<Page themeId="home">
<Header title="AsyncAPI" />
<Content>
<EntityProvider entity={(asyncapiApiEntity as any) as Entity}>
<EntityProvider entity={asyncapiApiEntity as any as Entity}>
<EntityApiDefinitionCard />
</EntityProvider>
</Content>
@@ -98,7 +98,7 @@ createDevApp()
<Page themeId="home">
<Header title="GraphQL" />
<Content>
<EntityProvider entity={(graphqlApiEntity as any) as Entity}>
<EntityProvider entity={graphqlApiEntity as any as Entity}>
<EntityApiDefinitionCard />
</EntityProvider>
</Content>
@@ -111,7 +111,7 @@ createDevApp()
<Page themeId="home">
<Header title="Other" />
<Content>
<EntityProvider entity={(otherApiEntity as any) as Entity}>
<EntityProvider entity={otherApiEntity as any as Entity}>
<EntityApiDefinitionCard />
</EntityProvider>
</Content>
+16 -14
View File
@@ -1,6 +1,7 @@
{
"name": "@backstage/plugin-api-docs",
"version": "0.6.2",
"description": "A Backstage plugin that helps represent API entities in the frontend",
"version": "0.6.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,19 +31,20 @@
},
"dependencies": {
"@asyncapi/react-component": "^0.23.0",
"@backstage/catalog-model": "^0.9.0",
"@backstage/core-components": "^0.1.5",
"@backstage/core-plugin-api": "^0.1.3",
"@backstage/plugin-catalog": "^0.6.7",
"@backstage/plugin-catalog-react": "^0.3.0",
"@backstage/theme": "^0.2.8",
"@backstage/catalog-model": "^0.9.3",
"@backstage/core-components": "^0.5.0",
"@backstage/core-plugin-api": "^0.1.8",
"@backstage/plugin-catalog": "^0.6.16",
"@backstage/plugin-catalog-react": "^0.5.0",
"@backstage/theme": "^0.2.10",
"@material-icons/font": "^1.0.2",
"@material-ui/core": "^4.11.0",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"@material-ui/lab": "4.0.0-alpha.57",
"@types/react": "*",
"graphiql": "^1.0.0-alpha.10",
"graphql": "^15.3.0",
"isomorphic-form-data": "^2.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
@@ -51,10 +53,10 @@
"swagger-ui-react": "^3.37.2"
},
"devDependencies": {
"@backstage/cli": "^0.7.4",
"@backstage/core-app-api": "^0.1.4",
"@backstage/dev-utils": "^0.2.2",
"@backstage/test-utils": "^0.1.14",
"@backstage/cli": "^0.7.13",
"@backstage/core-app-api": "^0.1.14",
"@backstage/dev-utils": "^0.2.10",
"@backstage/test-utils": "^0.1.17",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
@@ -111,7 +111,7 @@ paths:
},
};
const { getByText } = await renderInTestApp(
const { getByText, getAllByText } = await renderInTestApp(
<Wrapper>
<EntityProvider entity={apiEntity}>
<ApiDefinitionCard />
@@ -121,6 +121,10 @@ paths:
expect(getByText(/my-name/i)).toBeInTheDocument();
expect(getByText(/custom-type/i)).toBeInTheDocument();
expect(getByText(/Custom Definition/i)).toBeInTheDocument();
expect(
getAllByText(
(_text, element) => element?.textContent === 'Custom Definition',
).length,
).toBeGreaterThan(0);
});
});
@@ -17,6 +17,7 @@
import {
Content,
ContentHeader,
CreateButton,
PageWithHeader,
SupportButton,
TableColumn,
@@ -39,9 +40,7 @@ import {
UserListFilterKind,
UserListPicker,
} from '@backstage/plugin-catalog-react';
import { Button } from '@material-ui/core';
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { createComponentRouteRef } from '../../routes';
const defaultColumns: TableColumn<CatalogTableRow>[] = [
@@ -63,11 +62,11 @@ export const ApiExplorerPage = ({
initiallySelectedFilter = 'all',
columns,
}: ApiExplorerPageProps) => {
const createComponentLink = useRouteRef(createComponentRouteRef);
const configApi = useApi(configApiRef);
const generatedSubtitle = `${
configApi.getOptionalString('organization.name') ?? 'Backstage'
} API Explorer`;
const createComponentLink = useRouteRef(createComponentRouteRef);
return (
<PageWithHeader
@@ -78,16 +77,10 @@ export const ApiExplorerPage = ({
>
<Content>
<ContentHeader title="">
{createComponentLink && (
<Button
variant="contained"
color="primary"
component={RouterLink}
to={createComponentLink()}
>
Register Existing API
</Button>
)}
<CreateButton
title="Register Existing API"
to={createComponentLink?.()}
/>
<SupportButton>All your APIs</SupportButton>
</ContentHeader>
<EntityListProvider>
@@ -39,8 +39,8 @@ type Props = {
const columns: TableColumn<ApiEntity>[] = [
EntityTable.columns.createEntityRefColumn({ defaultKind: 'API' }),
EntityTable.columns.createOwnerColumn(),
EntityTable.columns.createSpecLifecycleColumn(),
createSpecApiTypeColumn(),
EntityTable.columns.createSpecLifecycleColumn(),
EntityTable.columns.createMetadataDescriptionColumn(),
];
@@ -36,7 +36,7 @@ export const apiEntityColumns: TableColumn<ApiEntity>[] = [
EntityTable.columns.createEntityRefColumn({ defaultKind: 'API' }),
EntityTable.columns.createSystemColumn(),
EntityTable.columns.createOwnerColumn(),
EntityTable.columns.createSpecLifecycleColumn(),
createSpecApiTypeColumn(),
EntityTable.columns.createSpecLifecycleColumn(),
EntityTable.columns.createMetadataDescriptionColumn(),
];
@@ -88,13 +88,15 @@ const useStyles = makeStyles(theme => ({
'& .asyncapi__enum': {
color: theme.palette.secondary.main,
},
'& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__channel-operations-list .asyncapi__messages-list-item .asyncapi__message, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div': {
'background-color': 'inherit',
},
'& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header, .asyncapi__channel-operation-oneOf-subscribe-header, .asyncapi__channel-operation-oneOf-publish-header, .asyncapi__channel-operation-message-header, .asyncapi__message-header, .asyncapi__message-header-title, .asyncapi__message-header-title > h3, .asyncapi__bindings, .asyncapi__bindings-header, .asyncapi__bindings-header > h4': {
'background-color': 'inherit',
color: theme.palette.text.primary,
},
'& .asyncapi__info, .asyncapi__channel, .asyncapi__channels > div, .asyncapi__schema, .asyncapi__channel-operations-list .asyncapi__messages-list-item .asyncapi__message, .asyncapi__message, .asyncapi__server, .asyncapi__servers > div, .asyncapi__messages > div, .asyncapi__schemas > div':
{
'background-color': 'inherit',
},
'& .asyncapi__channel-parameters-header, .asyncapi__channel-operations-header, .asyncapi__channel-operation-oneOf-subscribe-header, .asyncapi__channel-operation-oneOf-publish-header, .asyncapi__channel-operation-message-header, .asyncapi__message-header, .asyncapi__message-header-title, .asyncapi__message-header-title > h3, .asyncapi__bindings, .asyncapi__bindings-header, .asyncapi__bindings-header > h4':
{
'background-color': 'inherit',
color: theme.palette.text.primary,
},
'& .asyncapi__additional-properties-notice': {
color: theme.palette.text.hint,
},
@@ -104,10 +106,11 @@ const useStyles = makeStyles(theme => ({
'& .asyncapi__schema-example-header-title': {
color: theme.palette.text.secondary,
},
'& .asyncapi__message-headers-header, .asyncapi__message-payload-header, .asyncapi__server-variables-header, .asyncapi__server-security-header': {
'background-color': 'inherit',
color: theme.palette.text.secondary,
},
'& .asyncapi__message-headers-header, .asyncapi__message-payload-header, .asyncapi__server-variables-header, .asyncapi__server-security-header':
{
'background-color': 'inherit',
color: theme.palette.text.secondary,
},
'& .asyncapi__table-header': {
background: theme.palette.background.default,
},
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import React, { useEffect, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import React, { useEffect, useState } from 'react';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
@@ -30,22 +30,26 @@ const useStyles = makeStyles(theme => ({
'& .scheme-container': {
'background-color': theme.palette.background.default,
},
'& .opblock-tag, .opblock-tag small, table thead tr td, table thead tr th': {
color: theme.palette.text.primary,
'border-color': theme.palette.divider,
},
'& .opblock-tag, .opblock-tag small, table thead tr td, table thead tr th':
{
color: theme.palette.text.primary,
'border-color': theme.palette.divider,
},
'& section.models, section.models.is-open h4': {
'border-color': theme.palette.divider,
},
'& .opblock .opblock-summary-description, .parameter__type, table.headers td, .model-title, .model .property.primitive, section h3': {
color: theme.palette.text.secondary,
},
'& .opblock .opblock-summary-operation-id, .opblock .opblock-summary-path, .opblock .opblock-summary-path__deprecated, .opblock .opblock-section-header h4, .parameter__name, .response-col_status, .response-col_links, .responses-inner h4, .swagger-ui .responses-inner h5, .opblock-section-header .btn, .tab li, .info li, .info p, .info table, section.models h4, .info .title, table.model tr.description, .property-row': {
color: theme.palette.text.primary,
},
'& .opblock .opblock-section-header, .model-box, section.models .model-container': {
background: theme.palette.background.default,
},
'& .opblock .opblock-summary-description, .parameter__type, table.headers td, .model-title, .model .property.primitive, section h3':
{
color: theme.palette.text.secondary,
},
'& .opblock .opblock-summary-operation-id, .opblock .opblock-summary-path, .opblock .opblock-summary-path__deprecated, .opblock .opblock-section-header h4, .parameter__name, .response-col_status, .response-col_links, .responses-inner h4, .swagger-ui .responses-inner h5, .opblock-section-header .btn, .tab li, .info li, .info p, .info table, section.models h4, .info .title, table.model tr.description, .property-row':
{
color: theme.palette.text.primary,
},
'& .opblock .opblock-section-header, .model-box, section.models .model-container':
{
background: theme.palette.background.default,
},
'& .prop-format, .parameter__in': {
color: theme.palette.text.disabled,
},
@@ -53,9 +57,10 @@ const useStyles = makeStyles(theme => ({
color: theme.palette.text.primary,
'border-color': theme.palette.divider,
},
'& .opblock-description-wrapper p, .opblock-external-docs-wrapper p, .opblock-title_normal p, .response-control-media-type__accept-message, .opblock .opblock-section-header>label, .scheme-container .schemes>label, .info .base-url, .model': {
color: theme.palette.text.hint,
},
'& .opblock-description-wrapper p, .opblock-external-docs-wrapper p, .opblock-title_normal p, .response-control-media-type__accept-message, .opblock .opblock-section-header>label, .scheme-container .schemes>label, .info .base-url, .model':
{
color: theme.palette.text.hint,
},
'& .parameter__name.required:after': {
color: theme.palette.warning.dark,
},
@@ -83,7 +88,7 @@ export const OpenApiDefinitionWidget = ({ definition }: Props) => {
return (
<div className={classes.root}>
<SwaggerUI spec={def} />
<SwaggerUI spec={def} deepLinking />
</div>
);
};
@@ -20,9 +20,13 @@ import { PlainApiDefinitionWidget } from './PlainApiDefinitionWidget';
describe('<PlainApiDefinitionWidget />', () => {
it('renders plain text', async () => {
const { getByText } = await renderInTestApp(
const { getAllByText } = await renderInTestApp(
<PlainApiDefinitionWidget definition="Hello World" language="yaml" />,
);
expect(getByText(/Hello World/i)).toBeInTheDocument();
expect(
getAllByText((_text, element) => element?.textContent === 'Hello World')
.length,
).toBeGreaterThan(0);
});
});
+6
View File
@@ -14,6 +14,12 @@
* limitations under the License.
*/
/**
* A Backstage plugin that helps represent API entities in the frontend
*
* @packageDocumentation
*/
export * from './components';
export { apiDocsConfigRef } from './config';
export {
+8
View File
@@ -1,5 +1,13 @@
# @backstage/plugin-app-backend
## 0.3.16
### Patch Changes
- Updated dependencies
- @backstage/backend-common@0.9.0
- @backstage/config@0.1.8
## 0.3.15
### Patch Changes
-2
View File
@@ -24,6 +24,4 @@ export interface RouterOptions {
logger: Logger_2;
staticFallbackHandler?: express.Handler;
}
// (No @packageDocumentation comment for this package)
```
+4 -3
View File
@@ -1,6 +1,7 @@
{
"name": "@backstage/plugin-app-backend",
"version": "0.3.15",
"description": "A Backstage backend plugin that serves the Backstage frontend app",
"version": "0.3.16",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -29,9 +30,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.8.6",
"@backstage/backend-common": "^0.9.0",
"@backstage/config-loader": "^0.6.5",
"@backstage/config": "^0.1.5",
"@backstage/config": "^0.1.8",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
+6
View File
@@ -14,4 +14,10 @@
* limitations under the License.
*/
/**
* A Backstage backend plugin that serves the Backstage frontend app
*
* @packageDocumentation
*/
export * from './service/router';
+1 -1
View File
@@ -22,7 +22,7 @@ import { injectConfig } from './config';
jest.mock('fs-extra');
const fsMock = fs as jest.Mocked<typeof fs>;
const readFileMock = (fsMock.readFile as unknown) as jest.MockedFunction<
const readFileMock = fsMock.readFile as unknown as jest.MockedFunction<
(name: string) => Promise<string>
>;
+1 -2
View File
@@ -104,8 +104,7 @@ export async function createRouter(
// The Cache-Control header instructs the browser to not cache html files since it might
// link to static assets from recently deployed versions.
if (
((express.static.mime as unknown) as Mime).lookup(path) ===
'text/html'
(express.static.mime as unknown as Mime).lookup(path) === 'text/html'
) {
res.setHeader('Cache-Control', 'no-store, max-age=0');
}
+111
View File
@@ -1,5 +1,116 @@
# @backstage/plugin-auth-backend
## 0.4.1
### Patch Changes
- Updated dependencies
- @backstage/catalog-client@0.4.0
- @backstage/catalog-model@0.9.3
- @backstage/backend-common@0.9.4
- @backstage/config@0.1.10
## 0.4.0
### Minor Changes
- 19f45179a5: Bump `passport-saml` to version 3. This is a breaking change, in that it [now requires](https://github.com/node-saml/passport-saml/pull/548) the `auth.saml.cert` parameter to be set. If you are not using SAML auth, you can ignore this.
To update your settings, add something similar to the following to your app-config:
```yaml
auth:
saml:
# ... other settings ...
cert: 'MIICizCCAfQCCQCY8tKaMc0BMjANBgkqh ... W=='
```
For more information, see the [library README](https://github.com/node-saml/passport-saml#security-and-signatures).
### Patch Changes
- 560d6810f0: Fix a bug preventing an access token to be refreshed a second time with the GitLab provider.
- de5717872d: Use a more informative error message if the configured OIDC identity provider does not provide a `userinfo_endpoint` in its metadata.
- Updated dependencies
- @backstage/backend-common@0.9.3
## 0.3.24
### Patch Changes
- 2a105f451: Add a warning log message that `passport-saml` will require a `cert` config parameter imminently.
We intend to upgrade this package soon, past the point where we will start to strictly require the `auth.saml.cert` configuration parameter to be present. To avoid issues starting your auth backend, please
- 31892ee25: typo fix `tenentId` in Azure auth provider docs
- e9b1e2a9f: Added signIn and authHandler resolver for oAuth2 provider
- ca45b169d: Export GitHub to allow use with Identity resolver
- Updated dependencies
- @backstage/catalog-model@0.9.1
- @backstage/backend-common@0.9.1
## 0.3.23
### Patch Changes
- 392b36fa1: Added support for using authenticating via GitHub Apps in addition to GitHub OAuth Apps. It used to be possible to use GitHub Apps, but they did not handle session refresh correctly.
Note that GitHub Apps handle OAuth scope at the app installation level, meaning that the `scope` parameter for `getAccessToken` has no effect. When calling `getAccessToken` in open source plugins, one should still include the appropriate scope, but also document in the plugin README what scopes are required in the case of GitHub Apps.
In addition, the `authHandler` and `signInResolver` options have been implemented for the GitHub provider in the auth backend.
- ea9fe9567: Fixed a bug where OAuth state parameters would be serialized as the string `'undefined'`.
- 39fc3d7f8: Add Sign In and Handler resolver for GitLab provider
- Updated dependencies
- @backstage/backend-common@0.9.0
- @backstage/config@0.1.8
## 0.3.22
### Patch Changes
- 79d24a966: Fix an issue where the default app origin was not allowed to authenticate users.
## 0.3.21
### Patch Changes
- 72a31c29a: Add support for additional app origins
- Updated dependencies
- @backstage/backend-common@0.8.10
- @backstage/config@0.1.7
## 0.3.20
### Patch Changes
- 29f7cfffb: Added `resolveCatalogMembership` utility to query the catalog for additional authentication claims within sign-in resolvers.
- 8bedb75ae: Update Luxon dependency to 2.x
- bfe0ff93f: Add Sign In and Handler resolver for Okta provider
- Updated dependencies
- @backstage/backend-common@0.8.9
- @backstage/test-utils@0.1.17
## 0.3.19
### Patch Changes
- 4edca1bd0: Allow to configure SAML auth `acceptedClockSkewMs`
- b68f2c83c: Added the `disableRefresh` option to the `OAuth2` config
- Updated dependencies
- @backstage/test-utils@0.1.16
- @backstage/catalog-client@0.3.18
## 0.3.18
### Patch Changes
- 2567c066d: TokenIssuer is now exported so it may be used by auth providers that are not bundled with Backstage
- Updated dependencies
- @backstage/catalog-client@0.3.17
- @backstage/backend-common@0.8.7
- @backstage/test-utils@0.1.15
## 0.3.17
### Patch Changes
+99 -10
View File
@@ -84,6 +84,20 @@ export type BackstageIdentity = {
entity?: Entity;
};
// 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)
export const createGithubProvider: (
options?: GithubProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createGitlabProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const createGitlabProvider: (
options?: GitlabProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createGoogleProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -98,6 +112,25 @@ export const createMicrosoftProvider: (
options?: MicrosoftProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createOAuth2Provider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const createOAuth2Provider: (
options?: OAuth2ProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createOktaProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const createOktaProvider: (
_options?: OktaProviderOptions | 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)
export function createOriginFilter(config: Config): (origin: string) => boolean;
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -126,7 +159,40 @@ export const encodeState: (state: OAuthState) => string;
// @public (undocumented)
export const ensuresXRequestedWith: (req: express.Request) => boolean;
// Warning: (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "GithubOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type GithubOAuthResult = {
fullProfile: Profile;
params: {
scope: string;
expires_in?: string;
refresh_token_expires_in?: string;
};
accessToken: string;
refreshToken?: string;
};
// Warning: (ae-missing-release-tag) "GithubProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type GithubProviderOptions = {
authHandler?: AuthHandler<GithubOAuthResult>;
signIn?: {
resolver?: SignInResolver<GithubOAuthResult>;
};
};
// Warning: (ae-missing-release-tag) "GitlabProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type GitlabProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
signIn?: {
resolver?: SignInResolver<OAuthResult>;
};
};
// Warning: (ae-missing-release-tag) "googleEmailSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -171,6 +237,16 @@ export type MicrosoftProviderOptions = {
};
};
// Warning: (ae-missing-release-tag) "OAuth2ProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type OAuth2ProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
signIn?: {
resolver?: SignInResolver<OAuthResult>;
};
};
// Warning: (ae-missing-release-tag) "OAuthAdapter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -223,9 +299,7 @@ export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
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<{
handler(req: express.Request): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
@@ -303,6 +377,22 @@ export type OAuthStartRequest = express.Request<{}> & {
export type OAuthState = {
nonce: string;
env: string;
origin?: string;
};
// Warning: (ae-missing-release-tag) "oktaEmailSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const oktaEmailSignInResolver: SignInResolver<OAuthResult>;
// Warning: (ae-missing-release-tag) "OktaProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type OktaProviderOptions = {
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)
@@ -378,10 +468,9 @@ export type WebMessageResponse =
//
// src/identity/types.d.ts:25:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts
// 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/google/provider.d.ts:36:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:105:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:111:5 - (ae-forgotten-export) The symbol "ExperimentalIdentityResolver" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:128:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative
// (No @packageDocumentation comment for this package)
// src/providers/github/provider.d.ts:50:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
// src/providers/github/provider.d.ts:58:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:109:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:115:5 - (ae-forgotten-export) The symbol "ExperimentalIdentityResolver" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:132:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative
```
+3 -1
View File
@@ -48,11 +48,12 @@ export interface Config {
entryPoint: string;
logoutUrl?: string;
issuer: string;
cert?: string;
cert: string;
privateKey?: string;
decryptionPvk?: string;
signatureAlgorithm?: 'sha256' | 'sha512';
digestAlgorithm?: string;
acceptedClockSkewMs?: number;
};
okta?: {
[authEnv: string]: { [key: string]: string };
@@ -64,6 +65,7 @@ export interface Config {
authorizationUrl: string;
tokenUrl: string;
scope?: string;
disableRefresh?: boolean;
};
};
oidc?: {
+13 -10
View File
@@ -1,6 +1,7 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.3.17",
"description": "A Backstage backend plugin that handles authentication",
"version": "0.4.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -29,12 +30,12 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.8.6",
"@backstage/catalog-client": "^0.3.16",
"@backstage/catalog-model": "^0.9.0",
"@backstage/config": "^0.1.5",
"@backstage/backend-common": "^0.9.4",
"@backstage/catalog-client": "^0.4.0",
"@backstage/catalog-model": "^0.9.3",
"@backstage/config": "^0.1.10",
"@backstage/errors": "^0.1.1",
"@backstage/test-utils": "^0.1.14",
"@backstage/test-utils": "^0.1.17",
"@types/express": "^4.17.6",
"@types/passport": "^1.0.3",
"compression": "^1.7.4",
@@ -50,7 +51,9 @@
"jose": "^1.27.1",
"jwt-decode": "^3.1.0",
"knex": "^0.95.1",
"luxon": "^1.25.0",
"lodash": "^4.17.21",
"luxon": "^2.0.2",
"minimatch": "^3.0.3",
"morgan": "^1.10.0",
"node-cache": "^5.1.2",
"openid-client": "^4.2.1",
@@ -62,13 +65,13 @@
"passport-oauth2": "^1.5.0",
"passport-okta-oauth": "^0.0.1",
"passport-onelogin-oauth": "^0.0.1",
"passport-saml": "^2.0.0",
"passport-saml": "^3.1.2",
"uuid": "^8.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.7.4",
"@backstage/cli": "^0.7.13",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/express-session": "^1.17.2",
@@ -76,7 +79,7 @@
"@types/passport-github2": "^1.2.4",
"@types/passport-google-oauth20": "^2.0.3",
"@types/passport-microsoft": "^0.0.0",
"@types/passport-saml": "^1.1.2",
"@types/passport-saml": "^1.1.3",
"@types/passport-strategy": "^0.2.35",
"@types/xml2js": "^0.4.7",
"msw": "^0.29.0"
@@ -150,7 +150,7 @@ export class TokenFactory implements TokenIssuer {
// the new one. This also needs to be implemented cross-service though, meaning new services
// that boot up need to be able to grab an existing key to use for signing.
this.logger.info(`Created new signing key ${key.kid}`);
await this.keyStore.addKey((key.toJWK(false) as unknown) as AnyJWK);
await this.keyStore.addKey(key.toJWK(false) as unknown as AnyJWK);
// At this point we are allowed to start using the new key
return key as JSONWebKey;
+6
View File
@@ -14,6 +14,12 @@
* limitations under the License.
*/
/**
* A Backstage backend plugin that handles authentication
*
* @packageDocumentation
*/
export * from './service/router';
export { IdentityClient } from './identity';
export type { TokenIssuer } from './identity';
@@ -15,7 +15,11 @@
*/
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import {
RELATION_MEMBER_OF,
UserEntity,
UserEntityV1alpha1,
} from '@backstage/catalog-model';
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from './CatalogIdentityClient';
@@ -29,6 +33,7 @@ describe('CatalogIdentityClient', () => {
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
};
const tokenIssuer: jest.Mocked<TokenIssuer> = {
issueToken: jest.fn(),
@@ -37,12 +42,12 @@ describe('CatalogIdentityClient', () => {
afterEach(() => jest.resetAllMocks());
it('passes through the correct search params', async () => {
it('findUser passes through the correct search params', async () => {
catalogApi.getEntities.mockResolvedValueOnce({ items: [{} as UserEntity] });
tokenIssuer.issueToken.mockResolvedValue('my-token');
const client = new CatalogIdentityClient({
catalogApi: catalogApi,
tokenIssuer: tokenIssuer,
catalogApi,
tokenIssuer,
});
await client.findUser({ annotations: { key: 'value' } });
@@ -62,4 +67,88 @@ describe('CatalogIdentityClient', () => {
},
});
});
it('resolveCatalogMembership resolves membership', async () => {
const mockUsers: Array<UserEntityV1alpha1> = [
{
apiVersion: 'backstage.io/v1beta1',
kind: 'User',
metadata: {
name: 'inigom',
},
spec: {
memberOf: ['team-a'],
},
relations: [
{
type: RELATION_MEMBER_OF,
target: {
kind: 'Group',
namespace: 'default',
name: 'team-a',
},
},
],
},
{
apiVersion: 'backstage.io/v1beta1',
kind: 'User',
metadata: {
name: 'mpatinkin',
namespace: 'reality',
},
spec: {
memberOf: ['screen-actors-guild'],
},
relations: [
{
type: RELATION_MEMBER_OF,
target: {
kind: 'Group',
namespace: 'reality',
name: 'screen-actors-guild',
},
},
],
},
];
catalogApi.getEntities.mockResolvedValueOnce({ items: mockUsers });
const client = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const claims = await client.resolveCatalogMembership({
entityRefs: ['inigom', 'User:default/imontoya', 'User:reality/mpatinkin'],
});
expect(catalogApi.getEntities).toHaveBeenCalledWith({
filter: [
{
kind: 'user',
'metadata.namespace': 'default',
'metadata.name': 'inigom',
},
{
kind: 'user',
'metadata.namespace': 'default',
'metadata.name': 'imontoya',
},
{
kind: 'user',
'metadata.namespace': 'reality',
'metadata.name': 'mpatinkin',
},
],
});
expect(claims).toMatchObject([
'user:default/inigom',
'user:default/imontoya',
'user:reality/mpatinkin',
'group:default/team-a',
'group:reality/screen-actors-guild',
]);
});
});
@@ -14,15 +14,27 @@
* limitations under the License.
*/
import { Logger } from 'winston';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import {
EntityName,
parseEntityRef,
RELATION_MEMBER_OF,
stringifyEntityRef,
UserEntity,
} from '@backstage/catalog-model';
import { TokenIssuer } from '../../identity';
type UserQuery = {
annotations: Record<string, string>;
};
type MemberClaimQuery = {
entityRefs: string[];
logger?: Logger;
};
/**
* A catalog client tailored for reading out identity data from the catalog.
*/
@@ -64,4 +76,62 @@ export class CatalogIdentityClient {
return items[0] as UserEntity;
}
/**
* Resolve additional entity claims from the catalog, using the passed-in entity names. Designed
* to be used within a `signInResolver` where additional entity claims might be provided, but
* group membership and transient group membership lean on imported catalog relations.
*
* Returns a superset of the entity names that can be passed directly to `issueToken` as `ent`.
*/
async resolveCatalogMembership({
entityRefs,
logger,
}: MemberClaimQuery): Promise<string[]> {
const resolvedEntityRefs = entityRefs
.map((ref: string) => {
try {
const parsedRef = parseEntityRef(ref.toLocaleLowerCase('en-US'), {
defaultKind: 'user',
defaultNamespace: 'default',
});
return parsedRef;
} catch {
logger?.warn(`Failed to parse entityRef from ${ref}, ignoring`);
return null;
}
})
.filter((ref): ref is EntityName => ref !== null);
const filter = resolvedEntityRefs.map(ref => ({
kind: ref.kind,
'metadata.namespace': ref.namespace,
'metadata.name': ref.name,
}));
const entities = await this.catalogApi
.getEntities({ filter })
.then(r => r.items);
if (entityRefs.length !== entities.length) {
const foundEntityNames = entities.map(stringifyEntityRef);
const missingEntityNames = resolvedEntityRefs
.map(stringifyEntityRef)
.filter(s => !foundEntityNames.includes(s));
logger?.debug(`Entities not found for refs ${missingEntityNames.join()}`);
}
const memberOf = entities.flatMap(
e =>
e!.relations
?.filter(r => r.type === RELATION_MEMBER_OF)
.map(r => r.target) ?? [],
);
const newEntityRefs = [
...new Set(resolvedEntityRefs.concat(memberOf).map(stringifyEntityRef)),
];
logger?.debug(`Found catalog membership: ${newEntityRefs.join()}`);
return newEntityRefs;
}
}
@@ -32,10 +32,10 @@ describe('oauth helpers', () => {
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
const mockResponse = {
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
@@ -64,10 +64,10 @@ describe('oauth helpers', () => {
});
it('should post a message back with payload error', () => {
const mockResponse = ({
const mockResponse = {
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
@@ -84,13 +84,13 @@ describe('oauth helpers', () => {
it('should call postMessage twice but only one of them with target *', () => {
let responseBody = '';
const mockResponse = ({
const mockResponse = {
end: jest.fn(body => {
responseBody = body;
return this;
}),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
@@ -128,10 +128,10 @@ describe('oauth helpers', () => {
});
it('handles single quotes and unicode chars safely', () => {
const mockResponse = ({
const mockResponse = {
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
@@ -164,23 +164,23 @@ describe('oauth helpers', () => {
describe('ensuresXRequestedWith', () => {
it('should return false if no header present', () => {
const mockRequest = ({
const mockRequest = {
header: () => jest.fn(),
} as unknown) as express.Request;
} as unknown as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return false if header present with incorrect value', () => {
const mockRequest = ({
const mockRequest = {
header: () => 'INVALID',
} as unknown) as express.Request;
} as unknown as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return true if header present with correct value', () => {
const mockRequest = ({
const mockRequest = {
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
} as unknown as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
});
});
@@ -58,7 +58,7 @@ export const postMessageResponse = (
(window.opener || window.parent).postMessage(JSON.parse(authResponse), origin);
setTimeout(() => {
window.close();
}, 100); // same as the interval of the core-api lib/loginPopup.ts (to address race conditions)
}, 100); // same as the interval of the core-app-api lib/loginPopup.ts (to address race conditions)
`;
const hash = crypto.createHash('sha256').update(script).digest('base64');
@@ -64,6 +64,7 @@ describe('OAuthAdapter', () => {
issueToken: async () => 'my-id-token',
listPublicKeys: async () => ({ keys: [] }),
},
isOriginAllowed: () => false,
};
it('sets the correct headers in start', async () => {
@@ -71,19 +72,19 @@ describe('OAuthAdapter', () => {
providerInstance,
oAuthProviderOptions,
);
const mockRequest = ({
const mockRequest = {
query: {
scope: 'user',
env: 'development',
},
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
statusCode: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.start(mockRequest, mockResponse);
// nonce cookie checks
@@ -105,23 +106,24 @@ describe('OAuthAdapter', () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
isOriginAllowed: () => false,
});
const state = { nonce: 'nonce', env: 'development' };
const mockRequest = ({
const mockRequest = {
cookies: {
'test-provider-nonce': 'nonce',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.frameHandler(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
@@ -139,22 +141,23 @@ describe('OAuthAdapter', () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
isOriginAllowed: () => false,
});
const mockRequest = ({
const mockRequest = {
cookies: {
'test-provider-nonce': 'nonce',
},
query: {
state: 'nonce',
},
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.frameHandler(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledTimes(0);
@@ -164,17 +167,18 @@ describe('OAuthAdapter', () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
isOriginAllowed: () => false,
});
const mockRequest = ({
const mockRequest = {
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.logout(mockRequest, mockResponse);
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
@@ -190,20 +194,21 @@ describe('OAuthAdapter', () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
isOriginAllowed: () => false,
});
const mockRequest = ({
const mockRequest = {
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'token',
},
query: {},
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.json).toHaveBeenCalledTimes(1);
@@ -220,20 +225,21 @@ describe('OAuthAdapter', () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
isOriginAllowed: () => false,
});
const mockRequest = ({
const mockRequest = {
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'token',
},
query: {},
} as unknown) as express.Request;
} as unknown as express.Request;
const mockResponse = ({
const mockResponse = {
send: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
} as unknown as express.Response;
await oauthProvider.refresh(mockRequest, mockResponse);
expect(mockResponse.send).toHaveBeenCalledTimes(1);
@@ -22,11 +22,16 @@ import {
BackstageIdentity,
AuthProviderConfig,
} from '../../providers/types';
import { InputError } from '@backstage/errors';
import { InputError, NotAllowedError } from '@backstage/errors';
import { TokenIssuer } from '../../identity/types';
import { verifyNonce } from './helpers';
import { readState, verifyNonce } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
import { OAuthHandlers, OAuthStartRequest, OAuthRefreshRequest } from './types';
import {
OAuthHandlers,
OAuthStartRequest,
OAuthRefreshRequest,
OAuthState,
} from './types';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
@@ -40,6 +45,7 @@ export type Options = {
cookiePath: string;
appOrigin: string;
tokenIssuer: TokenIssuer;
isOriginAllowed: (origin: string) => boolean;
};
export class OAuthAdapter implements AuthProviderRouteHandlers {
@@ -61,6 +67,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
cookieDomain: url.hostname,
cookiePath,
secure,
isOriginAllowed: config.isOriginAllowed,
});
}
@@ -73,6 +80,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
// retrieve scopes from request
const scope = req.query.scope?.toString() ?? '';
const env = req.query.env?.toString();
const origin = req.query.origin?.toString();
if (!env) {
throw new InputError('No env provided in request query parameters');
@@ -86,7 +94,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
// set a nonce cookie before redirecting to oauth provider
this.setNonceCookie(res, nonce);
const state = { nonce: nonce, env: env };
const state = { nonce, env, origin };
const forwardReq = Object.assign(req, { scope, state });
const { url, status } = await this.handlers.start(
@@ -103,7 +111,22 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
req: express.Request,
res: express.Response,
): Promise<void> {
let appOrigin = this.options.appOrigin;
try {
const state: OAuthState = readState(req.query.state?.toString() ?? '');
if (state.origin) {
try {
appOrigin = new URL(state.origin).origin;
} catch {
throw new NotAllowedError('App origin is invalid, failed to parse');
}
if (!this.options.isOriginAllowed(appOrigin)) {
throw new NotAllowedError(`Origin '${appOrigin}' is not allowed`);
}
}
// verify nonce cookie and state cookie on callback
verifyNonce(req, this.options.providerId);
@@ -117,11 +140,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
response.providerInfo.scope = grantedScopes;
}
if (!this.options.disableRefresh) {
if (!refreshToken) {
throw new InputError('Missing refresh token');
}
if (refreshToken && !this.options.disableRefresh) {
// set new refresh token
this.setRefreshTokenCookie(res, refreshToken);
}
@@ -129,13 +148,13 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
await this.populateIdentity(response.backstageIdentity);
// post message back to popup if successful
return postMessageResponse(res, this.options.appOrigin, {
return postMessageResponse(res, appOrigin, {
type: 'authorization_response',
response,
});
} catch (error) {
// post error message back to popup if failure
return postMessageResponse(res, this.options.appOrigin, {
return postMessageResponse(res, appOrigin, {
type: 'authorization_response',
error: {
name: error.name,
@@ -151,10 +170,9 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
return;
}
if (!this.options.disableRefresh) {
// remove refresh token cookie before logout
this.removeRefreshTokenCookie(res);
}
// remove refresh token cookie if it is set
this.removeRefreshTokenCookie(res);
res.status(200).send('logout!');
}
@@ -15,30 +15,60 @@
*/
import express from 'express';
import { verifyNonce, encodeState } from './helpers';
import { verifyNonce, encodeState, readState } from './helpers';
describe('OAuthProvider Utils', () => {
describe('encodeState', () => {
it('should serialized values', () => {
const state = {
nonce: '123',
env: 'development',
origin: 'https://example.com',
};
const encoded = encodeState(state);
expect(encoded).toBe(
Buffer.from(
'nonce=123&env=development&origin=https%3A%2F%2Fexample.com',
).toString('hex'),
);
expect(readState(encoded)).toEqual(state);
});
it('should not include undefined values', () => {
const state = { nonce: '123', env: 'development', origin: undefined };
const encoded = encodeState(state);
expect(encoded).toBe(
Buffer.from('nonce=123&env=development').toString('hex'),
);
expect(readState(encoded)).toEqual(state);
});
});
describe('verifyNonce', () => {
it('should throw error if cookie nonce missing', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
const mockRequest = {
cookies: {},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
} as unknown as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Auth response is missing cookie nonce');
});
it('should throw error if state nonce missing', () => {
const mockRequest = ({
const mockRequest = {
cookies: {
'providera-nonce': 'NONCE',
},
query: {},
} as unknown) as express.Request;
} as unknown as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid state passed via request');
@@ -46,14 +76,14 @@ describe('OAuthProvider Utils', () => {
it('should throw error if nonce mismatch', () => {
const state = { nonce: 'NONCEB', env: 'development' };
const mockRequest = ({
const mockRequest = {
cookies: {
'providera-nonce': 'NONCEA',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
} as unknown as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid nonce');
@@ -61,14 +91,14 @@ describe('OAuthProvider Utils', () => {
it('should not throw any error if nonce matches', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
const mockRequest = {
cookies: {
'providera-nonce': 'NONCE',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
} as unknown as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).not.toThrow();
@@ -16,6 +16,7 @@
import express from 'express';
import { OAuthState } from './types';
import pickBy from 'lodash/pickBy';
export const readState = (stateString: string): OAuthState => {
const state = Object.fromEntries(
@@ -29,18 +30,16 @@ export const readState = (stateString: string): OAuthState => {
) {
throw Error(`Invalid state passed via request`);
}
return {
nonce: state.nonce,
env: state.env,
};
return state as OAuthState;
};
export const encodeState = (state: OAuthState): string => {
const searchParams = new URLSearchParams();
searchParams.append('nonce', state.nonce);
searchParams.append('env', state.env);
const stateString = new URLSearchParams(
pickBy<string>(state, value => value !== undefined),
).toString();
return Buffer.from(searchParams.toString(), 'utf-8').toString('hex');
return Buffer.from(stateString, 'utf-8').toString('hex');
};
export const verifyNonce = (req: express.Request, providerId: string) => {
+2 -3
View File
@@ -77,6 +77,7 @@ export type OAuthState = {
*/
nonce: string;
env: string;
origin?: string;
};
export type OAuthStartRequest = express.Request<{}> & {
@@ -106,9 +107,7 @@ export interface OAuthHandlers {
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
*/
handler(
req: express.Request,
): Promise<{
handler(req: express.Request): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
@@ -23,7 +23,7 @@ import {
executeRefreshTokenStrategy,
} from './PassportStrategyHelper';
const mockRequest = ({} as unknown) as express.Request;
const mockRequest = {} as unknown as express.Request;
describe('PassportStrategyHelper', () => {
class MyCustomRedirectStrategy extends passport.Strategy {
@@ -17,9 +17,11 @@
import express from 'express';
import passport from 'passport';
import jwtDecoder from 'jwt-decode';
import { ProfileInfo, RedirectInfo } from '../../providers/types';
import { InternalOAuthError } from 'passport-oauth2';
import { PassportProfile } from './types';
import { ProfileInfo, RedirectInfo } from '../../providers/types';
export type PassportDoneCallback<Res, Private = never> = (
err?: Error,
response?: Res,
@@ -27,11 +29,9 @@ export type PassportDoneCallback<Res, Private = never> = (
) => void;
export const makeProfileInfo = (
profile: passport.Profile,
profile: PassportProfile,
idToken?: string,
): ProfileInfo => {
let { displayName } = profile;
let email: string | undefined = undefined;
if (profile.emails && profile.emails.length > 0) {
const [firstEmail] = profile.emails;
@@ -39,11 +39,16 @@ export const makeProfileInfo = (
}
let picture: string | undefined = undefined;
if (profile.photos && profile.photos.length > 0) {
if (profile.avatarUrl) {
picture = profile.avatarUrl;
} else if (profile.photos && profile.photos.length > 0) {
const [firstPhoto] = profile.photos;
picture = firstPhoto.value;
}
let displayName: string | undefined =
profile.displayName ?? profile.username ?? profile.id;
if ((!email || !picture || !displayName) && idToken) {
try {
const decoded: Record<string, string> = jwtDecoder(idToken);
@@ -193,12 +198,12 @@ type ProviderStrategy = {
export const executeFetchUserProfileStrategy = async (
providerStrategy: passport.Strategy,
accessToken: string,
): Promise<passport.Profile> => {
): Promise<PassportProfile> => {
return new Promise((resolve, reject) => {
const anyStrategy = (providerStrategy as unknown) as ProviderStrategy;
const anyStrategy = providerStrategy as unknown as ProviderStrategy;
anyStrategy.userProfile(
accessToken,
(error: Error, rawProfile: passport.Profile) => {
(error: Error, rawProfile: PassportProfile) => {
if (error) {
reject(error);
} else {
@@ -13,5 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import passport from 'passport';
export { RegisterComponentResultDialog } from './RegisterComponentResultDialog';
export type PassportProfile = passport.Profile & {
avatarUrl?: string;
};
@@ -68,7 +68,6 @@ beforeEach(() => {
describe('AwsALBAuthProvider', () => {
const catalogApi = {
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
addLocation: jest.fn(),
removeLocationById: jest.fn(),
getEntities: jest.fn(),
@@ -77,24 +76,25 @@ describe('AwsALBAuthProvider', () => {
getLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
refreshEntity: jest.fn(),
};
const mockRequest = ({
const mockRequest = {
header: jest.fn(() => {
return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImZvbyIsImlzcyI6ImZvbyJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.T2BNS4G-6RoiFnXc8Q8TiwdWzTpNitY8jcsGM3N3-Yo';
}),
} as unknown) as express.Request;
const mockRequestWithoutJwt = ({
} as unknown as express.Request;
const mockRequestWithoutJwt = {
header: jest.fn(() => {
return undefined;
}),
} as unknown) as express.Request;
const mockResponse = ({
} as unknown as express.Request;
const mockResponse = {
end: jest.fn(),
header: () => jest.fn(),
json: jest.fn().mockReturnThis(),
status: jest.fn(),
} as unknown) as express.Response;
} as unknown as express.Response;
describe('should transform to type OAuthResponse', () => {
it('when JWT is valid and identity is resolved successfully', async () => {
@@ -15,4 +15,4 @@
*/
export { createGithubProvider } from './provider';
export type { GithubProviderOptions } from './provider';
export type { GithubOAuthResult, GithubProviderOptions } from './provider';
@@ -15,21 +15,47 @@
*/
import { Profile as PassportProfile } from 'passport';
import { GithubAuthProvider } from './provider';
import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
import {
GithubAuthProvider,
GithubOAuthResult,
githubDefaultSignInResolver,
} from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { OAuthResult } from '../../lib/oauth';
import { makeProfileInfo } from '../../lib/passport/PassportStrategyHelper';
const mockFrameHandler = (jest.spyOn(
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown) as jest.MockedFunction<
) as unknown as jest.MockedFunction<
() => Promise<{
result: Omit<OAuthResult, 'params'> & { params: { scope: string } };
result: GithubOAuthResult;
privateInfo: { refreshToken?: string };
}>
>;
describe('GithubAuthProvider', () => {
const tokenIssuer: TokenIssuer = {
listPublicKeys: jest.fn(),
async issueToken(params) {
return `token-for-${params.claims.sub}`;
},
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new GithubAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
signInResolver: githubDefaultSignInResolver,
authHandler: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
}),
callbackUrl: 'mock',
clientId: 'mock',
clientSecret: 'mock',
@@ -63,11 +89,10 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'jimmymarkum',
token: 'token-for-jimmymarkum',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: undefined,
idToken: undefined,
scope: 'read:scope',
},
profile: {
@@ -80,6 +105,7 @@ describe('GithubAuthProvider', () => {
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
@@ -87,7 +113,7 @@ describe('GithubAuthProvider', () => {
it('when "email" is missing, it should be able to create the profile without it', async () => {
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const fullProfile = ({
const fullProfile = {
id: 'uid-123',
username: 'jimmymarkum',
provider: 'github',
@@ -99,7 +125,7 @@ describe('GithubAuthProvider', () => {
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
},
],
} as unknown) as PassportProfile;
} as unknown as PassportProfile;
const params = {
scope: 'read:scope',
@@ -108,11 +134,10 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'jimmymarkum',
token: 'token-for-jimmymarkum',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: undefined,
idToken: undefined,
scope: 'read:scope',
},
profile: {
@@ -124,6 +149,7 @@ describe('GithubAuthProvider', () => {
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
@@ -131,7 +157,7 @@ describe('GithubAuthProvider', () => {
it('when "displayName" is missing, it should be able to create the profile and map "displayName" with "username"', async () => {
const accessToken = '19xasczxcm9n7gacn9jdgm19me';
const fullProfile = ({
const fullProfile = {
id: 'uid-123',
username: 'jimmymarkum',
provider: 'github',
@@ -143,7 +169,7 @@ describe('GithubAuthProvider', () => {
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
},
],
} as unknown) as PassportProfile;
} as unknown as PassportProfile;
const params = {
scope: 'read:scope',
@@ -151,11 +177,10 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'jimmymarkum',
token: 'token-for-jimmymarkum',
},
providerInfo: {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: undefined,
idToken: undefined,
scope: 'read:scope',
},
profile: {
@@ -167,6 +192,7 @@ describe('GithubAuthProvider', () => {
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
@@ -178,11 +204,11 @@ describe('GithubAuthProvider', () => {
const fullProfile = {
id: 'ipd12039',
username: 'daveboyle',
provider: 'gitlab',
provider: 'github',
displayName: 'Dave Boyle',
emails: [
{
value: 'daveboyle@gitlab.org',
value: 'daveboyle@github.org',
},
],
};
@@ -194,25 +220,63 @@ describe('GithubAuthProvider', () => {
const expected = {
backstageIdentity: {
id: 'daveboyle',
token: 'token-for-daveboyle',
},
providerInfo: {
accessToken:
'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe',
scope: 'read:user',
expiresInSeconds: undefined,
idToken: undefined,
},
profile: {
displayName: 'Dave Boyle',
email: 'daveboyle@gitlab.org',
email: 'daveboyle@github.org',
},
};
mockFrameHandler.mockResolvedValueOnce({
result: { fullProfile, accessToken, params },
privateInfo: {},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual(expected);
});
it('should forward a refresh token', async () => {
mockFrameHandler.mockResolvedValueOnce({
result: {
fullProfile: {
id: 'ipd12039',
provider: 'github',
displayName: 'Dave Boyle',
},
accessToken: 'a.b.c',
params: {
scope: 'read:user',
expires_in: '123',
},
},
privateInfo: { refreshToken: 'refresh-me' },
});
const response = await provider.handler({} as any);
expect(response).toEqual({
response: {
backstageIdentity: {
id: 'ipd12039',
token: 'token-for-ipd12039',
},
providerInfo: {
accessToken: 'a.b.c',
scope: 'read:user',
expiresInSeconds: 123,
},
profile: {
displayName: 'Dave Boyle',
},
},
refreshToken: 'refresh-me',
});
});
});
});
@@ -15,14 +15,23 @@
*/
import express from 'express';
import { Logger } from 'winston';
import { Profile as PassportProfile } from 'passport';
import { Strategy as GithubStrategy } from 'passport-github2';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
RedirectInfo,
AuthProviderFactory,
AuthHandler,
SignInResolver,
} from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
@@ -30,19 +39,52 @@ import {
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
OAuthResult,
OAuthRefreshRequest,
OAuthResponse,
} from '../../lib/oauth';
import { CatalogIdentityClient } from '../../lib/catalog';
import { TokenIssuer } from '../../identity';
type PrivateInfo = {
refreshToken?: string;
};
export type GithubOAuthResult = {
fullProfile: PassportProfile;
params: {
scope: string;
expires_in?: string;
refresh_token_expires_in?: string;
};
accessToken: string;
refreshToken?: string;
};
export type GithubAuthProviderOptions = OAuthProviderOptions & {
tokenUrl?: string;
userProfileUrl?: string;
authorizationUrl?: string;
signInResolver?: SignInResolver<GithubOAuthResult>;
authHandler: AuthHandler<GithubOAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class GithubAuthProvider implements OAuthHandlers {
private readonly _strategy: GithubStrategy;
private readonly signInResolver?: SignInResolver<GithubOAuthResult>;
private readonly authHandler: AuthHandler<GithubOAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: GithubAuthProviderOptions) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.tokenIssuer = options.tokenIssuer;
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this._strategy = new GithubStrategy(
{
clientID: options.clientId,
@@ -54,12 +96,12 @@ export class GithubAuthProvider implements OAuthHandlers {
},
(
accessToken: any,
_refreshToken: any,
refreshToken: any,
params: any,
fullProfile: any,
done: PassportDoneCallback<OAuthResult>,
done: PassportDoneCallback<GithubOAuthResult, PrivateInfo>,
) => {
done(undefined, { fullProfile, params, accessToken });
done(undefined, { fullProfile, params, accessToken }, { refreshToken });
},
);
}
@@ -72,48 +114,116 @@ export class GithubAuthProvider implements OAuthHandlers {
}
async handler(req: express.Request) {
const {
result: { fullProfile, accessToken, params },
} = await executeFrameHandlerStrategy<OAuthResult>(req, this._strategy);
const profile = makeProfileInfo(
{
...fullProfile,
id: fullProfile.username || fullProfile.id,
displayName:
fullProfile.displayName || fullProfile.username || fullProfile.id,
},
params.id_token,
);
const { result, privateInfo } = await executeFrameHandlerStrategy<
GithubOAuthResult,
PrivateInfo
>(req, this._strategy);
return {
response: {
profile,
providerInfo: {
accessToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
},
backstageIdentity: {
id: fullProfile.username || fullProfile.id,
},
},
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,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
}
private async handleResult(result: GithubOAuthResult) {
const { profile } = await this.authHandler(result);
const expiresInStr = result.params.expires_in;
const response: OAuthResponse = {
providerInfo: {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds:
expiresInStr === undefined ? undefined : Number(expiresInStr),
},
profile,
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
}
return response;
}
}
export type GithubProviderOptions = {};
export const githubDefaultSignInResolver: SignInResolver<GithubOAuthResult> =
async (info, ctx) => {
const { fullProfile } = info.result;
const userId = fullProfile.username || fullProfile.id;
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
export type GithubProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<GithubOAuthResult>;
/**
* 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<GithubOAuthResult>;
};
};
export const createGithubProvider = (
_options?: GithubProviderOptions,
options?: GithubProviderOptions,
): 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 enterpriseInstanceUrl = envConfig.getOptionalString(
'enterpriseInstanceUrl',
);
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const authorizationUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/authorize`
: undefined;
@@ -123,7 +233,30 @@ export const createGithubProvider = (
const userProfileUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/api/v3/user`
: undefined;
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<GithubOAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile }) => ({
profile: makeProfileInfo(fullProfile),
});
const signInResolverFn =
options?.signIn?.resolver ?? githubDefaultSignInResolver;
const signInResolver: SignInResolver<GithubOAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new GithubAuthProvider({
clientId,
@@ -132,10 +265,14 @@ export const createGithubProvider = (
tokenUrl,
userProfileUrl,
authorizationUrl,
signInResolver,
authHandler,
tokenIssuer,
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
persistScopes: true,
providerId,
tokenIssuer,
@@ -14,16 +14,47 @@
* limitations under the License.
*/
import { GitlabAuthProvider } from './provider';
import { GitlabAuthProvider, gitlabDefaultSignInResolver } from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { PassportProfile } from '../../lib/passport/types';
import { OAuthResult } from '../../lib/oauth';
import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = (jest.spyOn(
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown) as jest.MockedFunction<() => Promise<{ result: OAuthResult }>>;
) as unknown as jest.MockedFunction<() => Promise<{ result: OAuthResult }>>;
describe('GitlabAuthProvider', () => {
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new GitlabAuthProvider({
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
baseUrl: 'mock',
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://gitlab.com/lols',
},
}),
signInResolver: gitlabDefaultSignInResolver,
logger: getVoidLogger(),
});
it('should transform to type OAuthResponse', async () => {
const tests = [
{
@@ -60,12 +91,12 @@ describe('GitlabAuthProvider', () => {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: 100,
scope: 'user_read write_repository',
idToken: undefined,
},
profile: {
email: 'jimmymarkum@gmail.com',
displayName: 'Jimmy Markum',
picture:
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
picture: 'http://gitlab.com/lols',
},
},
},
@@ -102,26 +133,74 @@ describe('GitlabAuthProvider', () => {
accessToken:
'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe',
expiresInSeconds: 200,
idToken: undefined,
scope: 'read_repository',
},
profile: {
displayName: 'Dave Boyle',
email: 'daveboyle@gitlab.org',
picture: 'http://gitlab.com/lols',
},
},
},
];
const provider = new GitlabAuthProvider({
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
baseUrl: 'mock',
});
for (const test of tests) {
mockFrameHandler.mockResolvedValueOnce(test.input);
const { response } = await provider.handler({} as any);
expect(response).toEqual(test.expect);
}
});
it('should forward a new refresh token on refresh', async () => {
const mockRefreshToken = jest.spyOn(
helpers,
'executeRefreshTokenStrategy',
) as unknown as jest.MockedFunction<() => Promise<{}>>;
mockRefreshToken.mockResolvedValueOnce({
accessToken: 'a.b.c',
refreshToken: 'dont-forget-to-send-refresh',
params: {
id_token: 'my-id',
scope: 'read_user',
},
});
const mockUserProfile = jest.spyOn(
helpers,
'executeFetchUserProfileStrategy',
) as unknown as jest.MockedFunction<() => Promise<PassportProfile>>;
mockUserProfile.mockResolvedValueOnce({
id: 'uid-my-id',
username: 'mockuser',
provider: 'gitlab',
displayName: 'Mocked User',
emails: [
{
value: 'mockuser@gmail.com',
},
],
});
const response = 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',
},
});
});
});
@@ -16,6 +16,8 @@
import express from 'express';
import { Strategy as GitlabStrategy } from 'passport-gitlab2';
import { Logger } from 'winston';
import {
executeRedirectStrategy,
executeFrameHandlerStrategy,
@@ -24,7 +26,12 @@ import {
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
RedirectInfo,
AuthProviderFactory,
SignInResolver,
AuthHandler,
} from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
@@ -36,10 +43,8 @@ import {
encodeState,
OAuthResult,
} from '../../lib/oauth';
type FullProfile = OAuthResult['fullProfile'] & {
avatarUrl?: string;
};
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from '../../lib/catalog';
type PrivateInfo = {
refreshToken: string;
@@ -47,29 +52,54 @@ type PrivateInfo = {
export type GitlabAuthProviderOptions = OAuthProviderOptions & {
baseUrl: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
function transformProfile(fullProfile: FullProfile) {
const profile = makeProfileInfo({
...fullProfile,
photos: [
...(fullProfile.photos ?? []),
...(fullProfile.avatarUrl ? [{ value: fullProfile.avatarUrl }] : []),
],
});
export const gitlabDefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile, result } = info;
let id = result.fullProfile.id;
let id = fullProfile.id;
if (profile.email) {
id = profile.email.split('@')[0];
}
return { id, profile };
}
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: id, ent: [`user:default/${id}`] },
});
return { id, token };
};
export const gitlabDefaultAuthHandler: AuthHandler<OAuthResult> = async ({
fullProfile,
params,
}) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
export class GitlabAuthProvider implements OAuthHandlers {
private readonly _strategy: GitlabStrategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: GitlabAuthProviderOptions) {
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this.tokenIssuer = options.tokenIssuer;
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this._strategy = new GitlabStrategy(
{
clientID: options.clientId,
@@ -109,23 +139,9 @@ export class GitlabAuthProvider implements OAuthHandlers {
OAuthResult,
PrivateInfo
>(req, this._strategy);
const { accessToken, params } = result;
const { id, profile } = transformProfile(result.fullProfile);
return {
response: {
profile,
providerInfo: {
accessToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
idToken: params.id_token,
},
backstageIdentity: {
id,
},
},
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
@@ -145,30 +161,79 @@ export class GitlabAuthProvider implements OAuthHandlers {
this._strategy,
accessToken,
);
const { id, profile } = transformProfile(fullProfile);
return {
profile,
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: newRefreshToken,
});
}
private async handleResult(result: OAuthResult): Promise<OAuthResponse> {
const { profile } = await this.authHandler(result);
const response: OAuthResponse = {
providerInfo: {
accessToken,
refreshToken: newRefreshToken, // GitLab expires the old refresh token when used
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
backstageIdentity: {
id,
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,
},
profile,
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
}
return response;
}
}
export type GitlabProviderOptions = {};
export type GitlabProviderOptions = {
/**
* 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.
*/
/**
* Maps an auth result to a Backstage identity for the user.
*
* Set to `'email'` to use the default email-based sign in resolver, which will search
* the catalog for a single user entity that has a matching `microsoft.com/email` annotation.
*/
signIn?: {
resolver?: SignInResolver<OAuthResult>;
};
};
export const createGitlabProvider = (
_options?: GitlabProviderOptions,
options?: GitlabProviderOptions,
): 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');
@@ -176,11 +241,34 @@ export const createGitlabProvider = (
const baseUrl = audience || 'https://gitlab.com';
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> =
options?.authHandler ?? gitlabDefaultAuthHandler;
const signInResolverFn =
options?.signIn?.resolver ?? gitlabDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new GitlabAuthProvider({
clientId,
clientSecret,
callbackUrl,
baseUrl,
authHandler,
signInResolver,
catalogIdentityClient,
logger,
tokenIssuer,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -21,10 +21,10 @@ import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = (jest.spyOn(
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown) as jest.MockedFunction<
) as unknown as jest.MockedFunction<
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
@@ -40,8 +40,9 @@ describe('createGoogleProvider', () => {
const provider = new GoogleAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient,
tokenIssuer: (tokenIssuer as unknown) as TokenIssuer,
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
@@ -240,13 +240,10 @@ export type GoogleProviderOptions = {
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
/**
* Maps an auth result to a Backstage identity for the user.
*
* Set to `'email'` to use the default email-based sign in resolver, which will search
* the catalog for a single user entity that has a matching `google.com/email` annotation.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver?: SignInResolver<OAuthResult>;
};
};
@@ -14,8 +14,13 @@
* limitations under the License.
*/
export * from './github';
export * from './gitlab';
export * from './google';
export * from './microsoft';
export * from './oauth2';
export * from './okta';
export { factories as defaultAuthProviderFactories } from './factories';
// Export the minimal interface required for implementing a
@@ -21,10 +21,10 @@ import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = (jest.spyOn(
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown) as jest.MockedFunction<
) as unknown as jest.MockedFunction<
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
@@ -40,8 +40,9 @@ describe('createMicrosoftProvider', () => {
const provider = new MicrosoftAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient,
tokenIssuer: (tokenIssuer as unknown) as TokenIssuer,
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
@@ -220,24 +220,22 @@ export const microsoftEmailSignInResolver: SignInResolver<OAuthResult> = async (
return { id: entity.metadata.name, entity, token };
};
export const microsoftDefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
export const microsoftDefaultSignInResolver: SignInResolver<OAuthResult> =
async (info, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Profile contained no email');
}
if (!profile.email) {
throw new Error('Profile contained no email');
}
const userId = profile.email.split('@')[0];
const userId = profile.email.split('@')[0];
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
return { id: userId, token };
};
export type MicrosoftProviderOptions = {
/**
@@ -249,13 +247,10 @@ export type MicrosoftProviderOptions = {
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
/**
* Maps an auth result to a Backstage identity for the user.
*
* Set to `'email'` to use the default email-based sign in resolver, which will search
* the catalog for a single user entity that has a matching `microsoft.com/email` annotation.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver?: SignInResolver<OAuthResult>;
};
};
@@ -0,0 +1,94 @@
/*
* 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 { OAuth2AuthProvider } from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { OAuthResult } from '../../lib/oauth';
import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown as jest.MockedFunction<
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
describe('createOAuth2Provider', () => {
it('should auth', async () => {
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new OAuth2AuthProvider({
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://backstage.io/lols',
},
}),
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
authorizationUrl: 'mock',
tokenUrl: 'mock',
});
mockFrameHandler.mockResolvedValueOnce({
result: {
fullProfile: {
emails: [{ value: 'conrad@example.com' }],
displayName: 'Conrad',
id: 'conrad',
provider: 'oAuth2',
},
params: {
id_token: 'idToken',
scope: 'scope',
expires_in: 123,
},
accessToken: 'accessToken',
},
privateInfo: {
refreshToken: 'wacka',
},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual({
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
picture: 'http://backstage.io/lols',
},
});
});
});
@@ -18,15 +18,15 @@ import express from 'express';
import passport from 'passport';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
OAuthStartRequest,
encodeState,
OAuthAdapter,
OAuthEnvironmentHandler,
OAuthHandlers,
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthResult,
OAuthStartRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
@@ -36,22 +36,46 @@ import {
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
AuthHandler,
AuthProviderFactory,
RedirectInfo,
SignInResolver,
} from '../types';
import { CatalogIdentityClient } from '../../lib/catalog';
import { TokenIssuer } from '../../identity';
import { Logger } from 'winston';
type PrivateInfo = {
refreshToken: string;
};
export type OAuth2AuthProviderOptions = OAuthProviderOptions & {
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
authorizationUrl: string;
tokenUrl: string;
scope?: string;
logger: Logger;
};
export class OAuth2AuthProvider implements OAuthHandlers {
private readonly _strategy: OAuth2Strategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: OAuth2AuthProviderOptions) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.tokenIssuer = options.tokenIssuer;
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this._strategy = new OAuth2Strategy(
{
clientID: options.clientId,
@@ -102,18 +126,8 @@ export class OAuth2AuthProvider implements OAuthHandlers {
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,
};
}
@@ -130,46 +144,89 @@ export class OAuth2AuthProvider implements OAuthHandlers {
refreshToken: updatedRefreshToken,
} = refreshTokenResponse;
const rawProfile = await executeFetchUserProfileStrategy(
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
const profile = makeProfileInfo(rawProfile, params.id_token);
return this.populateIdentity({
providerInfo: {
accessToken,
refreshToken: updatedRefreshToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: updatedRefreshToken,
});
}
// 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,
refreshToken: result.refreshToken,
},
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 } };
return response;
}
}
export type OAuth2ProviderOptions = {};
export const oAuth2DefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Profile contained no email');
}
const userId = profile.email.split('@')[0];
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
export type OAuth2ProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
signIn?: {
resolver?: SignInResolver<OAuthResult>;
};
};
export const createOAuth2Provider = (
_options?: OAuth2ProviderOptions,
options?: OAuth2ProviderOptions,
): 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');
@@ -177,18 +234,46 @@ export const createOAuth2Provider = (
const authorizationUrl = envConfig.getString('authorizationUrl');
const tokenUrl = envConfig.getString('tokenUrl');
const scope = envConfig.getOptionalString('scope');
const disableRefresh =
envConfig.getOptionalBoolean('disableRefresh') ?? false;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolverFn =
options?.signIn?.resolver ?? oAuth2DefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new OAuth2AuthProvider({
clientId,
clientSecret,
tokenIssuer,
catalogIdentityClient,
callbackUrl,
signInResolver,
authHandler,
authorizationUrl,
tokenUrl,
scope,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
disableRefresh,
providerId,
tokenIssuer,
});
@@ -70,7 +70,7 @@ describe('OidcAuthProvider', () => {
rest.get('https://oidc.test/.well-known/openid-configuration', handler),
);
const provider = new OidcAuthProvider(clientMetadata);
const { strategy } = ((await (provider as any).implementation) as any) as {
const { strategy } = (await (provider as any).implementation) as any as {
strategy: {
_client: ClientMetadata;
_issuer: IssuerMetadata;
@@ -138,7 +138,7 @@ describe('OidcAuthProvider', () => {
const req = {
method: 'GET',
url: 'https://oidc.test/?code=test2',
session: ({ 'oidc:oidc.test': 'test' } as any) as Session,
session: { 'oidc:oidc.test': 'test' } as any as Session,
} as express.Request;
await provider.handler(req);
expect(requestSequence).toEqual([0, 1, 2].map(i => requests[i].url));
@@ -157,6 +157,11 @@ export class OidcAuthProvider implements OAuthHandlers {
userinfo: UserinfoResponse,
done: PassportDoneCallback<AuthResult, PrivateInfo>,
) => {
if (typeof done !== 'function') {
throw new Error(
'OIDC IdP must provide a userinfo_endpoint in the metadata response',
);
}
done(
undefined,
{ tokenset, userinfo },
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export { createOktaProvider } from './provider';
export { createOktaProvider, oktaEmailSignInResolver } from './provider';
export type { OktaProviderOptions } from './provider';
@@ -0,0 +1,105 @@
/*
* 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 { OktaAuthProvider } from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { OAuthResult } from '../../lib/oauth';
import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown as jest.MockedFunction<
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
describe('createOktaProvider', () => {
it('should auth', async () => {
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new OktaAuthProvider({
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,
},
}),
audience: 'http://example.com',
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
});
mockFrameHandler.mockResolvedValueOnce({
result: {
fullProfile: {
emails: [
{
type: 'work',
value: 'conrad@example.com',
},
],
displayName: 'Conrad',
name: {
familyName: 'Ribas',
givenName: 'Francisco',
},
id: 'conrad',
provider: 'okta',
photos: [
{
value: 'some-data',
},
],
},
params: {
id_token: 'idToken',
scope: 'scope',
expires_in: 123,
},
accessToken: 'accessToken',
},
privateInfo: {
refreshToken: 'wacka',
},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual({
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
},
});
});
});
@@ -35,8 +35,16 @@ import {
executeFetchUserProfileStrategy,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
AuthProviderFactory,
AuthHandler,
RedirectInfo,
SignInResolver,
} from '../types';
import { StateStore } from 'passport-oauth2';
import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog';
import { TokenIssuer } from '../../identity';
import { Logger } from 'winston';
type PrivateInfo = {
refreshToken: string;
@@ -44,10 +52,20 @@ type PrivateInfo = {
export type OktaAuthProviderOptions = OAuthProviderOptions & {
audience: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class OktaAuthProvider 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;
/**
* Due to passport-okta-oauth forcing options.state = true,
@@ -67,6 +85,12 @@ export class OktaAuthProvider implements OAuthHandlers {
};
constructor(options: OktaAuthProviderOptions) {
this._signInResolver = options.signInResolver;
this._authHandler = options.authHandler;
this._tokenIssuer = options.tokenIssuer;
this._catalogIdentityClient = options.catalogIdentityClient;
this._logger = options.logger;
this._strategy = new OktaStrategy(
{
clientID: options.clientId,
@@ -117,18 +141,8 @@ export class OktaAuthProvider implements OAuthHandlers {
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,
};
}
@@ -144,52 +158,154 @@ export class OktaAuthProvider implements OAuthHandlers {
this._strategy,
accessToken,
);
const profile = makeProfileInfo(fullProfile, params.id_token);
return this.populateIdentity({
providerInfo: {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.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('Okta 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,
},
);
}
// TODO(Rugvip): Hardcoded to the local part of the email for now
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
return response;
}
}
export type OktaProviderOptions = {};
export const oktaEmailSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Okta profile contained no email');
}
const entity = await ctx.catalogIdentityClient.findUser({
annotations: {
'okta.com/email': profile.email,
},
});
const claims = getEntityClaims(entity);
const token = await ctx.tokenIssuer.issueToken({ claims });
return { id: entity.metadata.name, entity, token };
};
export const oktaDefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Okta profile contained no email');
}
// TODO(Rugvip): Hardcoded to the local part of the email for now
const userId = profile.email.split('@')[0];
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
export type OktaProviderOptions = {
/**
* 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>;
};
};
export const createOktaProvider = (
_options?: OktaProviderOptions,
): 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 audience = envConfig.getString('audience');
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 signInResolverFn =
_options?.signIn?.resolver ?? oktaDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new OktaAuthProvider({
audience,
clientId,
clientSecret,
callbackUrl,
authHandler,
signInResolver,
tokenIssuer,
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -125,23 +125,19 @@ export const createSamlProvider = (
entryPoint: config.getString('entryPoint'),
logoutUrl: config.getOptionalString('logoutUrl'),
issuer: config.getString('issuer'),
cert: config.getOptionalString('cert'),
cert: config.getString('cert'),
privateCert: config.getOptionalString('privateKey'),
decryptionPvk: config.getOptionalString('decryptionPvk'),
signatureAlgorithm: config.getOptionalString('signatureAlgorithm') as
| SignatureAlgorithm
| undefined,
digestAlgorithm: config.getOptionalString('digestAlgorithm'),
acceptedClockSkewMs: config.getOptionalNumber('acceptedClockSkewMs'),
tokenIssuer,
appUrl: globalConfig.appUrl,
};
// passport-saml will return an error if the `cert` key is set, and the value is empty.
// Since we read from config (such as environment variables) an empty string should be equal to being unset.
if (!opts.cert) {
delete opts.cert;
}
return new SamlAuthProvider(opts);
};
};
@@ -34,6 +34,11 @@ export type AuthProviderConfig = {
* The base URL of the app as provided by app.baseUrl
*/
appUrl: string;
/**
* A function that is called to check whether an origin is allowed to receive the authentication result.
*/
isOriginAllowed: (origin: string) => boolean;
};
export type RedirectInfo = {
@@ -0,0 +1,54 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import { createOriginFilter } from './router';
describe('Auth origin filtering', () => {
const config = new ConfigReader({
app: {
baseUrl: 'http://example.com/extra-path',
},
auth: {
experimentalExtraAllowedOrigins: ['https://test-*.example.net'],
},
});
it('Will explode, invalid origin', () => {
const origin = 'https://test.example.net';
expect(createOriginFilter(config)(origin)).toBeFalsy();
});
it('Will explode, invalid origin domain', () => {
const origin = 'https://test-1234.examplee.net';
expect(createOriginFilter(config)(origin)).toBeFalsy();
});
it("Won't explode, uses app origin", () => {
const origin = 'http://example.com';
expect(createOriginFilter(config)(origin)).toBeTruthy();
});
it("Won't explode, valid origin with numbers", () => {
const origin = 'https://test-1234.example.net';
expect(createOriginFilter(config)(origin)).toBeTruthy();
});
it("Won't explode, valid origin with chars and numbers", () => {
const origin = 'https://test-test1234.example.net';
expect(createOriginFilter(config)(origin)).toBeTruthy();
});
});
+27 -1
View File
@@ -32,6 +32,7 @@ import { Config } from '@backstage/config';
import { createOidcRouter, DatabaseKeyStore, TokenFactory } from '../identity';
import session from 'express-session';
import passport from 'passport';
import { Minimatch } from 'minimatch';
type ProviderFactories = { [s: string]: AuthProviderFactory };
@@ -88,6 +89,8 @@ export async function createRouter({
const providersConfig = config.getConfig('auth.providers');
const configuredProviders = providersConfig.keys();
const isOriginAllowed = createOriginFilter(config);
for (const [providerId, providerFactory] of Object.entries(
allProviderFactories,
)) {
@@ -96,7 +99,7 @@ export async function createRouter({
try {
const provider = providerFactory({
providerId,
globalConfig: { baseUrl: authUrl, appUrl },
globalConfig: { baseUrl: authUrl, appUrl, isOriginAllowed },
config: providersConfig.getConfig(providerId),
logger,
tokenIssuer,
@@ -158,3 +161,26 @@ export async function createRouter({
return router;
}
export function createOriginFilter(
config: Config,
): (origin: string) => boolean {
const appUrl = config.getString('app.baseUrl');
const { origin: appOrigin } = new URL(appUrl);
const allowedOrigins = config.getOptionalStringArray(
'auth.experimentalExtraAllowedOrigins',
);
const allowedOriginPatterns =
allowedOrigins?.map(
pattern => new Minimatch(pattern, { nocase: true, noglobstar: true }),
) ?? [];
return origin => {
if (origin === appOrigin) {
return true;
}
return allowedOriginPatterns.some(pattern => pattern.match(origin));
};
}
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+24
View File
@@ -0,0 +1,24 @@
# Azure DevOps Backend
Simple plugin that proxies requests to the [Azure DevOps](https://docs.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-6.1) API.
## Setup
The following values are read from the configuration file:
```yaml
azureDevOps:
host: dev.azure.com
token: ${AZURE_TOKEN}
organization: my-company
```
Configuration Details:
- `host` and `token` can be the same as the ones used for the `integration` section
- `AZURE_TOKEN` environment variable must be set to a [Personal Access Token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page) with read access to both Code and Build
- `organization` is your Azure DevOps Organization name or for Azure DevOps Server (on-premise) this will be your Collection name
## Links
- [The Backstage homepage](https://backstage.io)
@@ -0,0 +1,70 @@
## API Report File for "@backstage/plugin-azure-devops-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces';
import { BuildResult } from 'azure-devops-node-api/interfaces/BuildInterfaces';
import { BuildStatus } from 'azure-devops-node-api/interfaces/BuildInterfaces';
import { Config } from '@backstage/config';
import express from 'express';
import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { Logger as Logger_2 } from 'winston';
import { WebApi } from 'azure-devops-node-api';
// Warning: (ae-missing-release-tag) "AzureDevOpsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export class AzureDevOpsApi {
constructor(logger: Logger_2, webApi: WebApi);
// (undocumented)
getBuildList(
projectName: string,
repoId: string,
top: number,
): Promise<Build[]>;
// (undocumented)
getGitRepository(
projectName: string,
repoName: string,
): Promise<GitRepository>;
// (undocumented)
getRepoBuilds(
projectName: string,
repoName: string,
top: number,
): Promise<RepoBuild[]>;
}
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// Warning: (ae-missing-release-tag) "RepoBuild" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type RepoBuild = {
id?: number;
title: string;
link: string;
status?: BuildStatus;
result?: BuildResult;
queueTime?: Date;
source: string;
};
// Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
azureDevOpsApi?: AzureDevOpsApi;
// (undocumented)
config: Config;
// (undocumented)
logger: Logger_2;
}
// (No @packageDocumentation comment for this package)
```
+34
View File
@@ -0,0 +1,34 @@
/*
* 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.
*/
export interface Config {
/** Configuration options for the azure-devops-backend plugin */
azureDevOps: {
/**
* The hostname of the given Azure instance
*/
host: string;
/**
* Token used to authenticate requests.
* @visibility secret
*/
token: string;
/**
* The organization of the given Azure instance
*/
organization: string;
};
}
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@backstage/plugin-azure-devops-backend",
"version": "0.1.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.2",
"@backstage/config": "^0.1.9",
"@types/express": "^4.17.6",
"azure-devops-node-api": "^11.0.1",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.7.11",
"@types/supertest": "^2.0.8",
"supertest": "^4.0.2",
"msw": "^0.29.0"
},
"files": [
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
@@ -0,0 +1,97 @@
/*
* 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 { repoBuildFromBuild } from './AzureDevOpsApi';
import { RepoBuild } from './types';
import {
Build,
BuildResult,
BuildStatus,
DefinitionReference,
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
describe('AzureDevOpsApi', () => {
describe('repoBuildFromBuild', () => {
it('should return RepoBuild from Build', () => {
const inputBuildDefinition: DefinitionReference = {
name: 'My Build Definition',
};
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: BuildStatus.Completed,
result: BuildResult.Succeeded,
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
sourceBranch: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: inputBuildDefinition,
_links: inputLinks,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'My Build Definition - Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
status: BuildStatus.Completed,
result: BuildResult.Succeeded,
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
source: 'refs/heads/develop (f4f78b31)',
};
expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild);
});
});
describe('repoBuildFromBuild with no Build definition name', () => {
it('should return RepoBuild with only Build Number for title', () => {
const inputLinks: any = {
web: {
href: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
},
};
const inputBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: BuildStatus.Completed,
result: BuildResult.Succeeded,
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
sourceBranch: 'refs/heads/develop',
sourceVersion: 'f4f78b3100b2923982bdf60c89c57ce6fd2d9a1c',
definition: undefined,
_links: inputLinks,
};
const outputRepoBuild: RepoBuild = {
id: 1,
title: 'Build-1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
status: BuildStatus.Completed,
result: BuildResult.Succeeded,
queueTime: new Date('2020-09-12T06:10:23.9325232Z'),
source: 'refs/heads/develop (f4f78b31)',
};
expect(repoBuildFromBuild(inputBuild)).toEqual(outputRepoBuild);
});
});
});
@@ -0,0 +1,106 @@
/*
* 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 { Logger } from 'winston';
import { WebApi } from 'azure-devops-node-api';
import { RepoBuild } from './types';
import { Build } from 'azure-devops-node-api/interfaces/BuildInterfaces';
export class AzureDevOpsApi {
constructor(
private readonly logger: Logger,
private readonly webApi: WebApi,
) {}
async getGitRepository(projectName: string, repoName: string) {
if (this.logger) {
this.logger.debug(
`Calling Azure DevOps REST API, getting Repository ${repoName} for Project ${projectName}`,
);
}
const client = await this.webApi.getGitApi();
return client.getRepository(repoName, projectName);
}
async getBuildList(projectName: string, repoId: string, top: number) {
if (this.logger) {
this.logger.debug(
`Calling Azure DevOps REST API, getting up to ${top} Builds for Repository Id ${repoId} for Project ${projectName}`,
);
}
const client = await this.webApi.getBuildApi();
return client.getBuilds(
projectName,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
top,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
repoId,
'TfsGit',
);
}
async getRepoBuilds(projectName: string, repoName: string, top: number) {
if (this.logger) {
this.logger.debug(
`Calling Azure DevOps REST API, getting up to ${top} Builds for Repository ${repoName} for Project ${projectName}`,
);
}
const gitRepository = await this.getGitRepository(projectName, repoName);
const buildList = await this.getBuildList(
projectName,
gitRepository.id as string,
top,
);
const repoBuilds: RepoBuild[] = buildList.map(build => {
return repoBuildFromBuild(build);
});
return repoBuilds;
}
}
export function repoBuildFromBuild(build: Build) {
return {
id: build.id,
title: [build.definition?.name, build.buildNumber]
.filter(Boolean)
.join(' - '),
link: build._links?.web.href,
status: build.status,
result: build.result,
queueTime: build.queueTime,
source: `${build.sourceBranch} (${build.sourceVersion?.substr(0, 8)})`,
};
}
@@ -0,0 +1,18 @@
/*
* 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 { AzureDevOpsApi } from './AzureDevOpsApi';
export type { RepoBuild } from './types';
@@ -0,0 +1,30 @@
/*
* 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 {
BuildResult,
BuildStatus,
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
export type RepoBuild = {
id?: number;
title: string;
link: string;
status?: BuildStatus;
result?: BuildResult;
queueTime?: Date;
source: string;
};
+18
View File
@@ -0,0 +1,18 @@
/*
* 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 { AzureDevOpsApi } from './api';
export type { RepoBuild } from './api';
export * from './service/router';
+33
View File
@@ -0,0 +1,33 @@
/*
* 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1,196 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import express from 'express';
import request from 'supertest';
import { AzureDevOpsApi } from '../api';
import { createRouter } from './router';
import { RepoBuild } from '../api/types';
import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces';
import {
Build,
BuildResult,
BuildStatus,
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
describe('createRouter', () => {
let azureDevOpsApi: jest.Mocked<AzureDevOpsApi>;
let app: express.Express;
beforeAll(async () => {
azureDevOpsApi = {
getGitRepository: jest.fn(),
getBuildList: jest.fn(),
getRepoBuilds: jest.fn(),
} as any;
const router = await createRouter({
azureDevOpsApi,
logger: getVoidLogger(),
config: new ConfigReader({
azureDevOps: {
token: 'foo',
host: 'host.com',
organization: 'myOrg',
top: 5,
},
}),
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /health', () => {
it('returns ok', async () => {
const response = await request(app).get('/health');
expect(response.status).toEqual(200);
expect(response.body).toEqual({ status: 'ok' });
});
});
describe('GET /repository/:projectName/:repoName', () => {
it('fetches a single repository', async () => {
const gitRepository: GitRepository = {
id: 'af4ae3af-e747-4129-9bbc-d1329f6b0998',
name: 'myRepo',
url: 'https://host.com/repo',
defaultBranch: 'refs/heads/develop',
sshUrl: 'ssh://host.com/repo',
webUrl: 'https://host.com/webRepo',
};
azureDevOpsApi.getGitRepository.mockResolvedValueOnce(gitRepository);
const response = await request(app).get('/repository/myProject/myRepo');
expect(azureDevOpsApi.getGitRepository).toHaveBeenCalledWith(
'myProject',
'myRepo',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(gitRepository);
});
});
describe('GET /builds/:projectName/:repoId', () => {
it('fetches a list of builds', async () => {
const firstBuild: Build = {
id: 1,
buildNumber: 'Build-1',
status: BuildStatus.Completed,
result: BuildResult.Succeeded,
queueTime: undefined,
sourceBranch: 'refs/heads/develop',
sourceVersion: '9bedf67800b2923982bdf60c89c57ce6fd2d9a1c',
};
const secondBuild: Build = {
id: 2,
buildNumber: 'Build-2',
status: BuildStatus.InProgress,
result: BuildResult.None,
queueTime: undefined,
sourceBranch: 'refs/heads/develop',
sourceVersion: '13c988d4f15e06bcdd0b0af290086a3079cdadb0',
};
const thirdBuild: Build = {
id: 3,
buildNumber: 'Build-3',
status: BuildStatus.Completed,
result: BuildResult.PartiallySucceeded,
queueTime: undefined,
sourceBranch: 'refs/heads/develop',
sourceVersion: 'f4f78b319c308600eab015a5d6529add21660dc1',
};
const builds: Build[] = [firstBuild, secondBuild, thirdBuild];
azureDevOpsApi.getBuildList.mockResolvedValueOnce(builds);
const response = await request(app)
.get('/builds/myProject/af4ae3af-e747-4129-9bbc-d1329f6b0998')
.query({ top: '40' });
expect(azureDevOpsApi.getBuildList).toHaveBeenCalledWith(
'myProject',
'af4ae3af-e747-4129-9bbc-d1329f6b0998',
40,
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(builds);
});
});
describe('GET /repo-builds/:projectName/:repoName', () => {
it('fetches a list of repo builds', async () => {
const firstRepoBuild: RepoBuild = {
id: 1,
title: 'My Build Definition - Build 1',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=1',
status: BuildStatus.Completed,
result: BuildResult.PartiallySucceeded,
queueTime: undefined,
source: 'refs/heads/develop (f4f78b31)',
};
const secondRepoBuild: RepoBuild = {
id: 2,
title: 'My Build Definition - Build 2',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=2',
status: BuildStatus.InProgress,
result: BuildResult.None,
queueTime: undefined,
source: 'refs/heads/develop (13c988d4)',
};
const thirdRepoBuild: RepoBuild = {
id: 3,
title: 'My Build Definition - Build 3',
link: 'https://host.com/myOrg/0bcc0c0d-2d02/_build/results?buildId=3',
status: BuildStatus.Completed,
result: BuildResult.Succeeded,
queueTime: undefined,
source: 'refs/heads/develop (9bedf678)',
};
const repoBuilds: RepoBuild[] = [
firstRepoBuild,
secondRepoBuild,
thirdRepoBuild,
];
azureDevOpsApi.getRepoBuilds.mockResolvedValueOnce(repoBuilds);
const response = await request(app)
.get('/repo-builds/myProject/myRepo')
.query({ top: '50' });
expect(azureDevOpsApi.getRepoBuilds).toHaveBeenCalledWith(
'myProject',
'myRepo',
50,
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(repoBuilds);
});
});
});
@@ -0,0 +1,89 @@
/*
* 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 { errorHandler } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
import { AzureDevOpsApi } from '../api';
const DEFAULT_TOP: number = 10;
export interface RouterOptions {
azureDevOpsApi?: AzureDevOpsApi;
logger: Logger;
config: Config;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger } = options;
const config = options.config.getConfig('azureDevOps');
const token = config.getString('token');
const host = config.getString('host');
const organization = config.getString('organization');
const authHandler = getPersonalAccessTokenHandler(token);
const webApi = new WebApi(`https://${host}/${organization}`, authHandler);
const azureDevOpsApi =
options.azureDevOpsApi || new AzureDevOpsApi(logger, webApi);
const router = Router();
router.use(express.json());
router.get('/health', (_req, res) => {
res.status(200).json({ status: 'ok' });
});
router.get('/repository/:projectName/:repoName', async (req, res) => {
const { projectName, repoName } = req.params;
const gitRepository = await azureDevOpsApi.getGitRepository(
projectName,
repoName,
);
res.status(200).json(gitRepository);
});
router.get('/builds/:projectName/:repoId', async (req, res) => {
const { projectName, repoId } = req.params;
const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP;
const buildList = await azureDevOpsApi.getBuildList(
projectName,
repoId,
top,
);
res.status(200).json(buildList);
});
router.get('/repo-builds/:projectName/:repoName', async (req, res) => {
const { projectName, repoName } = req.params;
const top = req.query.top ? Number(req.query.top) : DEFAULT_TOP;
const gitRepository = await azureDevOpsApi.getRepoBuilds(
projectName,
repoName,
top,
);
res.status(200).json(gitRepository);
});
router.use(errorHandler());
return router;
}
@@ -0,0 +1,57 @@
/*
* 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 {
createServiceBuilder,
loadBackendConfig,
} from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'azure-devops-backend' });
const config = await loadBackendConfig({ logger, argv: process.argv });
logger.debug('Starting application server...');
const router = await createRouter({
logger,
config,
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/azure-devops', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -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.
*/
export {};
+18
View File
@@ -1,5 +1,23 @@
# @backstage/plugin-badges-backend
## 0.1.10
### Patch Changes
- Updated dependencies
- @backstage/catalog-client@0.4.0
- @backstage/catalog-model@0.9.3
- @backstage/backend-common@0.9.4
- @backstage/config@0.1.10
## 0.1.9
### Patch Changes
- Updated dependencies
- @backstage/backend-common@0.9.0
- @backstage/config@0.1.8
## 0.1.8
### Patch Changes
-2
View File
@@ -139,6 +139,4 @@ export interface RouterOptions {
// (undocumented)
discovery: PluginEndpointDiscovery;
}
// (No @packageDocumentation comment for this package)
```
+7 -6
View File
@@ -1,6 +1,7 @@
{
"name": "@backstage/plugin-badges-backend",
"version": "0.1.8",
"description": "A Backstage backend plugin that generates README badges for your entities",
"version": "0.1.10",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,10 +31,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.8.5",
"@backstage/catalog-client": "^0.3.16",
"@backstage/catalog-model": "^0.9.0",
"@backstage/config": "^0.1.5",
"@backstage/backend-common": "^0.9.4",
"@backstage/catalog-client": "^0.4.0",
"@backstage/catalog-model": "^0.9.3",
"@backstage/config": "^0.1.10",
"@backstage/errors": "^0.1.1",
"@types/express": "^4.17.6",
"badge-maker": "^3.3.0",
@@ -45,7 +46,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.7.1",
"@backstage/cli": "^0.7.13",
"@types/supertest": "^2.0.8",
"supertest": "^6.1.3"
},
+6
View File
@@ -14,6 +14,12 @@
* limitations under the License.
*/
/**
* A Backstage backend plugin that generates README badges for your entities
*
* @packageDocumentation
*/
export * from './badges';
export * from './lib';
export * from './service/router';
@@ -42,7 +42,7 @@ describe('DefaultBadgeBuilder', () => {
createBadge: () => badge,
},
failbadge: {
createBadge: () => (undefined as unknown) as Badge, // force a bad return value..
createBadge: () => undefined as unknown as Badge, // force a bad return value..
},
invalidbadge: {
createBadge: () => ({ style: 'wrong' as BadgeStyle, ...badge }),
@@ -66,6 +66,7 @@ describe('createRouter', () => {
getLocationById: jest.fn(),
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
};
config = new ConfigReader({
+53
View File
@@ -1,5 +1,58 @@
# @backstage/plugin-badges
## 0.2.10
### Patch Changes
- Updated dependencies
- @backstage/core-components@0.5.0
- @backstage/plugin-catalog-react@0.5.0
- @backstage/catalog-model@0.9.3
## 0.2.9
### Patch Changes
- 9f1362dcc1: Upgrade `@material-ui/lab` to `4.0.0-alpha.57`.
- Updated dependencies
- @backstage/core-components@0.4.2
- @backstage/plugin-catalog-react@0.4.6
- @backstage/core-plugin-api@0.1.8
## 0.2.8
### Patch Changes
- Updated dependencies
- @backstage/plugin-catalog-react@0.4.5
- @backstage/core-components@0.4.0
- @backstage/catalog-model@0.9.1
## 0.2.7
### Patch Changes
- Updated dependencies
- @backstage/core-components@0.3.0
- @backstage/core-plugin-api@0.1.5
- @backstage/plugin-catalog-react@0.4.1
## 0.2.6
### Patch Changes
- 9d40fcb1e: - Bumping `material-ui/core` version to at least `4.12.2` as they made some breaking changes in later versions which broke `Pagination` of the `Table`.
- Switching out `material-table` to `@material-table/core` for support for the later versions of `material-ui/core`
- This causes a minor API change to `@backstage/core-components` as the interface for `Table` re-exports the `prop` from the underlying `Table` components.
- `onChangeRowsPerPage` has been renamed to `onRowsPerPageChange`
- `onChangePage` has been renamed to `onPageChange`
- Migration guide is here: https://material-table-core.com/docs/breaking-changes
- Updated dependencies
- @backstage/core-components@0.2.0
- @backstage/plugin-catalog-react@0.4.0
- @backstage/core-plugin-api@0.1.4
- @backstage/theme@0.2.9
## 0.2.5
### Patch Changes
+140 -2
View File
@@ -10,8 +10,146 @@ link below for more details.
## Entity badges
To get markdown code for the entity badges, access the `Badges` context menu
(three dots in the upper right corner) of an entity page, which will popup a
badges dialog showing all available badges for that entity.
(three dots in the upper right corner) of an entity page like this:
![Badges Context Menu](./doc/badges-context-menu.png)
This will popup a badges dialog showing all available badges for that entity like this:
![Badges Dialog](./doc/badges-dialog.png)
## Sample Badges
Here are some samples of badges for the `artists-lookup` service in the Demo Backstage site:
- Component: [![Link to artist-lookup in Backstage Demo, Component: artist-lookup](https://demo.backstage.io/api/badges/entity/default/component/artist-lookup/badge/pingback 'Link to artist-lookup in Backstage Demo')](https://demo.backstage.io/catalog/default/component/artist-lookup)
- Lifecycle: [![Entity lifecycle badge, lifecycle: experimental](https://demo.backstage.io/api/badges/entity/default/component/artist-lookup/badge/lifecycle 'Entity lifecycle badge')](https://demo.backstage.io/catalog/default/component/artist-lookup)
- Owner: [![Entity owner badge, owner: team-a](https://demo.backstage.io/api/badges/entity/default/component/artist-lookup/badge/owner 'Entity owner badge')](https://demo.backstage.io/catalog/default/component/artist-lookup)
- Docs: [![Entity docs badge, docs: artist-lookup](https://demo.backstage.io/api/badges/entity/default/component/artist-lookup/badge/docs 'Entity docs badge')](https://demo.backstage.io/catalog/default/component/artist-lookup/docs)
## Usage
### Install the package
```bash
yarn add @backstage/plugin-badges
```
### Register plugin
This plugin requires explicit registration, so you will need to add it to your App's `plugins.ts` file:
```ts
// ...
export { badgesPlugin } from '@backstage/plugin-badges';
```
If you don't have a `plugins.ts` file see: [troubleshooting](#troubleshooting)
### Update your EntityPage
In your `EntityPage.tsx` file located in `packages\app\src\components\catalog` we'll need to make a few changes to get the Badges context menu added to the UI.
First we need to add the following imports:
```ts
import { EntityBadgesDialog } from '@backstage/plugin-badges';
import BadgeIcon from '@material-ui/icons/CallToAction';
```
Next we'll update the React import that looks like this:
```ts
import React from 'react';
```
To look like this:
```ts
import React, { ReactNode, useMemo, useState } from 'react';
```
Then we have to add this chunk of code after all the imports but before any of the other code:
```ts
const EntityLayoutWrapper = (props: { children?: ReactNode }) => {
const [badgesDialogOpen, setBadgesDialogOpen] = useState(false);
const extraMenuItems = useMemo(() => {
return [
{
title: 'Badges',
Icon: BadgeIcon,
onClick: () => setBadgesDialogOpen(true),
},
];
}, []);
return (
<>
<EntityLayout UNSTABLE_extraContextMenuItems={extraMenuItems}>
{props.children}
</EntityLayout>
<EntityBadgesDialog
open={badgesDialogOpen}
onClose={() => setBadgesDialogOpen(false)}
/>
</>
);
};
```
The last step is to wrap all the entity pages in the `EntityLayoutWrapper` like this:
```diff
const defaultEntityPage = (
+ <EntityLayoutWrapper>
<EntityLayout.Route path="/" title="Overview">
{overviewContent}
</EntityLayout.Route>
<EntityLayout.Route path="/docs" title="Docs">
<EntityTechdocsContent />
</EntityLayout.Route>
<EntityLayout.Route path="/todos" title="TODOs">
<EntityTodoContent />
</EntityLayout.Route>
+ </EntityLayoutWrapper>
);
```
Note: the above only shows an example for the `defaultEntityPage` for a full example of this you can look at [this EntityPage](https://github.com/backstage/backstage/blob/1fd9e6f601cabe42af8eb20b5d200ad1988ba309/packages/app/src/components/catalog/EntityPage.tsx#L318)
## Troubleshooting
If you don't have a `plugins.ts` file, you can create it with the path `packages/app/src/plugins.ts` and then import it into your `App.tsx`:
```diff
+ import * as plugins from './plugins';
const app = createApp({
apis,
+ plugins: Object.values(plugins),
bindRoutes({ bind }) {
/* ... */
},
});
```
Or simply edit `App.tsx` with:
```diff
+ import { badgesPlugin } from '@backstage/plugin-badges'
const app = createApp({
apis,
+ plugins: [badgesPlugin],
bindRoutes({ bind }) {
/* ... */
},
});
```
## Links
-2
View File
@@ -22,6 +22,4 @@ export const EntityBadgesDialog: ({
open: boolean;
onClose?: (() => any) | undefined;
}) => JSX.Element;
// (No @packageDocumentation comment for this package)
```

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