diff --git a/app-config.yaml b/app-config.yaml index 1eca3664b7..957d17990a 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -120,10 +120,9 @@ auth: audience: $secret: env: GITLAB_BASE_URL - # saml: - # development: - # entryPoint: "http://localhost:7001/" - # issuer: "passport-saml" + saml: + entryPoint: "http://localhost:7001/" + issuer: "passport-saml" okta: development: clientId: diff --git a/catalog-info.yaml b/catalog-info.yaml index 4221ee6dc6..937a55bfca 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -3,10 +3,9 @@ kind: Component metadata: name: backstage description: | - Backstage is an open-source developer portal that puts the developer experience first. + Backstage is an open-source developer portal that puts the developer experience first. annotations: github.com/project-slug: spotify/backstage - backstage.io/github-actions-id: spotify/backstage backstage.io/techdocs-ref: github:https://github.com/spotify/backstage.git spec: type: library diff --git a/docs/auth/add-auth-provider.md b/docs/auth/add-auth-provider.md index a2830dcf4f..7e4f5c8e2f 100644 --- a/docs/auth/add-auth-provider.md +++ b/docs/auth/add-auth-provider.md @@ -48,22 +48,35 @@ provider class which implements a handler for the chosen framework. #### Adding an OAuth based provider If we're adding an `OAuth` based provider we would implement the -[OAuthProviderHandlers](#OAuthProviderHandlers) interface. +[OAuthProviderHandlers](#OAuthProviderHandlers) interface. By implementing this +interface we can use the `OAuthProvider` class provided by `lib/oauth`, meaning +we don't need to implement the full +[AuthProviderRouteHandlers](#AuthProviderRouteHandlers) interface that providers +otherwise need to implement. -The provider class takes the provider's configuration as a class parameter. It -also imports the `Strategy` from the passport package. +The provider class takes the provider's options as a class parameter. It also +imports the `Strategy` from the passport package. ```ts import { Strategy as ProviderAStrategy } from 'passport-provider-a'; +export type ProviderAProviderOptions = OAuthProviderOptions & { + // extra options here +} + export class ProviderAAuthProvider implements OAuthProviderHandlers { - private readonly providerConfig: AuthProviderConfig; private readonly _strategy: ProviderAStrategy; - constructor(providerConfig: AuthProviderConfig) { - this.providerConfig = providerConfig; + constructor(options: ProviderAProviderOptions) { this._strategy = new ProviderAStrategy( - { ...providerConfig.options }, + { + clientID: options.clientId, + clientSecret: options.clientSecret, + callbackURL: options.callbackUrl, + passReqToCallback: false as true, + response_type: 'code', + /// ... etc + } verifyFunction, // See the "Verify Callback" section ); } @@ -82,14 +95,18 @@ An non-`OAuth` based provider could implement [AuthProviderRouteHandlers](#AuthProviderRouteHandlers) instead. ```ts +type ProviderAOptions = { + // ... +}; + export class ProviderAAuthProvider implements AuthProviderRouteHandlers { - private readonly providerConfig: AuthProviderConfig; private readonly _strategy: ProviderAStrategy; - constructor(providerConfig: AuthProviderConfig) { - this.providerConfig = providerConfig; + constructor(options: ProviderAOptions) { this._strategy = new ProviderAStrategy( - { ...providerConfig.options }, + { + // ... + }, verifyFunction, // See the "Verify Callback" section ); } @@ -101,31 +118,61 @@ export class ProviderAAuthProvider implements AuthProviderRouteHandlers { } ``` -#### Create method +#### Factory function -Each provider exports a create method that creates the provider instance, -optionally extending a supported authorization framework. This method exists to -allow for flexibility if additional frameworks are supported in the future. +Each provider exports a factory function that instantiates the provider. The +factory should implement [AuthProviderFactory](#AuthProviderFactory), which +passes in a object with utilities for configuration, logging, token issuing, +etc. The factory should return an implementation of +[AuthProviderRouteHandlers](#AuthProviderRouteHandlers). -Implementing OAuth by returning an instance of `OAuthProvider` based of the -provider's class: +The factory is what decides the mapping from +[static configuration](../conf/index.md) to the creation of auth providers. For +example, OAuth providers use `OAuthEnvironmentHandler` to allow for multiple +different configurations, one for each environment, which looks like this; ```ts -export function createProviderAProvider(config: AuthProviderConfig) { - const provider = new ProviderAAuthProvider(config); - const oauthProvider = new OAuthProvider(provider, config.provider, true); - return oauthProvider; -} +export const createOktaProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + // read options from config + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + + // instantiate our OAuthProviderHandlers implementation + const provider = new OktaAuthProvider({ + audience, + clientId, + clientSecret, + callbackUrl, + }); + + // Wrap the OAuthProviderHandlers with OAuthProvider, which implements AuthProviderRouteHandlers + return OAuthProvider.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); + }); ``` -Not extending with OAuth, the main difference here is that the create method is -returning a instance of the class without adding the OAuth authorization -framework to it. +The purpose of the different environments is to allow for a single auth-backend +to serve as the authentication service for multiple different frontend +environments, such as local development, staging, and production. + +The factory function for other providers can be a lot simpler, as they might not +have configuration for each environment. Looking something like this: ```ts -export function createProviderAProvider(config: AuthProviderConfig) { - return new ProviderAAuthProvider(config); -} +export const createProviderAProvider: AuthProviderFactory = ({ config }) => { + const a = config.getString('a'); + const b = config.getString('b'); + + return new ProviderAAuthProvider({ a, b }); +}; ``` #### Verify Callback @@ -144,7 +191,7 @@ export function createProviderAProvider(config: AuthProviderConfig) { > http://www.passportjs.org/docs/configure/ **`plugins/auth-backend/src/providers/providerA/index.ts`** is simply -re-exporting the create method to be used for hooking the provider up to the +re-exporting the factory function to be used for hooking the provider up to the backend. ```ts @@ -153,26 +200,14 @@ export { createProviderAProvider } from './provider'; ### Hook it up to the backend -**`plugins/auth-backend/src/providers/config.ts`** The provider needs to be -configured properly so you need to add it to the list of configured providers, -all of which implement [AuthProviderConfig](#AuthProviderConfig): - -```ts -export const providers = [ - { - provider: 'providerA', # used as an identifier - options: { ... }, # consult the provider documentation for which options you should provide - disableRefresh: true # if the provider lacks refresh tokens - }, -``` - **`plugins/auth-backend/src/providers/factories.ts`** When the `auth-backend` starts it sets up routing for all the available providers by calling -`createAuthProviderRouter` on each provider. You need to import the create -method from the provider and add it to the factory: +`createAuthProviderRouter` on each provider. You need to import the factory +function from the provider and add it to the factory: ```ts import { createProviderAProvider } from './providerA'; + const factories: { [providerId: string]: AuthProviderFactory } = { providerA: createProviderAProvider, }; @@ -203,10 +238,21 @@ web browser and you should be able to trigger the authorization flow. ```ts export interface OAuthProviderHandlers { - start(req: express.Request, options: any): Promise; - handler(req: express.Request): Promise; - refresh?(refreshToken: string, scope: string): Promise; - logout?(): Promise; + start( + req: express.Request, + options: Record, + ): Promise; + handler( + req: express.Request, + ): Promise<{ + response: AuthResponse; + refreshToken?: string; + }>; + refresh?( + refreshToken: string, + scope: string, + ): Promise>; + logout?(): Promise; } ``` @@ -221,12 +267,17 @@ export interface AuthProviderRouteHandlers { } ``` -##### AuthProviderConfig +##### AuthProviderFactory ```ts -export type AuthProviderConfig = { - provider: string; - options: any; - disableRefresh?: boolean; +export type AuthProviderFactoryOptions = { + globalConfig: AuthProviderConfig; + config: Config; + logger: Logger; + tokenIssuer: TokenIssuer; }; + +export type AuthProviderFactory = ( + options: AuthProviderFactoryOptions, +) => AuthProviderRouteHandlers; ``` diff --git a/docs/auth/auth-backend-classes.md b/docs/auth/auth-backend-classes.md index 9034a3d054..0147c66dd9 100644 --- a/docs/auth/auth-backend-classes.md +++ b/docs/auth/auth-backend-classes.md @@ -26,7 +26,7 @@ refer to the type documentation under `plugins/auth-backend/src/providers/types.ts`. There are currently two different classes for two authentication mechanisms that -implement this interface: an `OAuthProvider` for [OAuth](https://oauth.net/2/) +implement this interface: an `OAuthAdapter` for [OAuth](https://oauth.net/2/) based mechanisms and a `SAMLAuthProvider` for [SAML](http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html) based mechanisms. @@ -50,11 +50,12 @@ OAuth2) providers, you can configure them by setting the right variables in Each authentication provider (except SAML) needs five parameters: an OAuth client ID, a client secret, an authorization endpoint and a token endpoint, and an app origin. The app origin is the URL at which the frontend of the -application is hosted. This is required because the application opens a popup -window to perform the authentication, and once the flow is completed, the popup -window sends a `postMessage` to the frontend application to indicate the result -of the operation. Also this URL is used to verify that authentication requests -are coming from only this endpoint. +application is hosted, and it is read from the `app.baseUrl` config. This is +required because the application opens a popup window to perform the +authentication, and once the flow is completed, the popup window sends a +`postMessage` to the frontend application to indicate the result of the +operation. Also this URL is used to verify that authentication requests are +coming from only this endpoint. These values are configured via the `app-config.yaml` present in the root of your app folder. @@ -64,8 +65,6 @@ auth: providers: google: development: - appOrigin: "http://localhost:3000/" - secure: false clientId: $secret: env: AUTH_GOOGLE_CLIENT_ID @@ -74,8 +73,6 @@ auth: env: AUTH_GOOGLE_CLIENT_SECRET github: development: - appOrigin: "http://localhost:3000/" - secure: false clientId: $secret: env: AUTH_GITHUB_CLIENT_ID @@ -87,8 +84,6 @@ auth: env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL gitlab: development: - appOrigin: "http://localhost:3000/" - secure: false clientId: $secret: ... @@ -96,24 +91,34 @@ auth: ## Technical Notes -### EnvironmentHandler +### OAuthEnvironmentHandler The concept of an "env" is core to the way the auth backend works. It uses an `env` query parameter to identify the environment in which the application is running (`development`, `staging`, `production`, etc). Each runtime can support multiple environments at the same time and the right handler for each request is identified and dispatched to based on the `env` parameter. All -`AuthProviderRouteHandlers` are wrapped within an `EnvironmentHandler`. +`AuthProviderRouteHandlers` are wrapped within an `OAuthEnvironmentHandler`. -An `EnvironmentHandler` takes an ID for each provider that it wraps, the -handlers for each env the provider is supported in, and a function that, given a -`Request` as argument, can extract the information about the env under which it -should be processed. +To instantiate multiple OAuth providers for different environments, use +`OAuthEnvironmentHandler.mapConfig`. It's a helper to iterate over a +configuration object that is a map of environment to configurations. See one of +the existing OAuth providers for an example of how it is used. -Each provider exposes a factory function `createXProvider` (where X is the name -of the provider) that takes the global config, env and other parameters and -returns an `AuthProviderRouteHandlers` for each env, and an `envIdentifier` -function to identify the env of a request. +Given the following configuration: + +```yaml +development: + clientId: abc + clientSecret: secret +production: + clientId: xyz + clientSecret: supersecret +``` + +The `OAuthEnvironmentHandler.mapConfig(config, envConfig => ...)` call will +split the `config` by the top level `development` and `production` keys, and +pass on each block as `envConfig`. For a list of currently available providers, look in the `factories` module located in `plugins/auth-backend/src/providers/factories.ts` diff --git a/docs/features/software-catalog/descriptor-format.md b/docs/features/software-catalog/descriptor-format.md index 78dfcf365c..e90792c628 100644 --- a/docs/features/software-catalog/descriptor-format.md +++ b/docs/features/software-catalog/descriptor-format.md @@ -235,6 +235,9 @@ The `backstage.io/` prefix is reserved for use by Backstage core components. Values can be of any length, but are limited to being strings. +There is a list of [well-known annotations](well-known-annotations.md), but +anybody is free to add more annotations as they see fit. + ### `tags` [optional] A list of single-valued strings, for example to classify catalog entities in @@ -290,7 +293,7 @@ Exactly equal to `backstage.io/v1alpha1` and `Component`, respectively. The type of component as a string, e.g. `website`. This field is required. -The software catalog accepts any type value, but an organisation should take +The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface @@ -304,9 +307,9 @@ The current set of well-known and common values for this field is: ### `spec.lifecycle` [required] -The lifecyle state of the component, e.g. `production`. This field is required. +The lifecycle state of the component, e.g. `production`. This field is required. -The software catalog accepts any lifecycle value, but an organisation should +The software catalog accepts any lifecycle value, but an organization should take great care to establish a proper taxonomy for these. The current set of well-known and common values for this field is: @@ -418,7 +421,7 @@ potentially search and group templates by these tags. The type of component as a string, e.g. `website`. This field is optional but recommended. -The software catalog accepts any type value, but an organisation should take +The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface @@ -508,7 +511,7 @@ Exactly equal to `backstage.io/v1alpha1` and `API`, respectively. The type of the API definition as a string, e.g. `openapi`. This field is required. -The software catalog accepts any type value, but an organisation should take +The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, an OpenAPI type API may be displayed using an OpenAPI viewer tooling in diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md new file mode 100644 index 0000000000..159cfb4ca1 --- /dev/null +++ b/docs/features/software-catalog/well-known-annotations.md @@ -0,0 +1,116 @@ +--- +id: well-known-annotations +title: Well-known Annotations on Catalog Entities +sidebar_label: Well-known Annotations +--- + +This section lists a number of well known +[annotations](descriptor-format.md#annotations-optional), that have defined +semantics. They can be attached to catalog entities and consumed by plugins as +needed. + +## Annotations + +This is a (non-exhaustive) list of annotations that are known to be in active +use. + +### backstage.io/managed-by-location + +```yaml +# Example: +metadata: + annotations: + backstage.io/managed-by-location: github:http://github.com/spotify/backstage/catalog-info.yaml +``` + +The value of this annotation is a so called location reference string, that +points to the source from which the entity was originally fetched. This +annotation is added automatically by the catalog as it fetches the data from a +registered location, and is not meant to normally be written by humans. The +annotation may point to any type of generic location that the catalog supports, +so it cannot be relied on to always be specifically of type `github`, nor that +it even represents a single file. Note also that a single location can be the +source of many entities, so it represents a many-to-one relationship. + +The format of the value is `:`. Note that the target may also +contain colons, so it is not advisable to naively split the value on `:` and +expecting a two-item array out of it. The format of the target part is +type-dependent and could conceivably even be an empty string, but the separator +colon is always present. + +### backstage.io/techdocs-ref + +```yaml +# Example: +metadata: + annotations: + backstage.io/techdocs-ref: github:https://github.com/spotify/backstage.git +``` + +The value of this annotation is a location reference string (see above). If this +annotation is specified, it is expected to point to a repository that the +TechDocs system can read and generate docs from. + +### backstage.io/jenkins-github-folder + +```yaml +# Example: +metadata: + annotations: + backstage.io/jenkins-github-folder: folder-name/job-name +``` + +The value of this annotation is the path to a job on Jenkins, that builds this +entity. + +Specifying this annotation may enable Jenkins related features in Backstage for +that entity. + +### github.com/project-slug + +```yaml +# Example: +metadata: + annotations: + github.com/project-slug: spotify/backstage +``` + +The value of this annotation is the so-called slug that identifies a project on +[GitHub](https://github.com) that is related to this entity. It is on the format +`/`, and is the same as can be seen in the URL location +bar of the browser when viewing that project. + +Specifying this annotation will enable GitHub related features in Backstage for +that entity. + +### sentry.io/project-slug + +```yaml +# Example: +metadata: + annotations: + sentry.io/project-slug: pump-station +``` + +The value of this annotation is the so-called slug (or alternatively, the ID) of +a [Sentry](https://sentry.io) project within your organization. The organization +slug is currently not configurable on a per-entity basis, but is assumed to be +the same for all entities in the catalog. + +Specifying this annotation may enable Sentry related features in Backstage for +that entity. + +## Deprecated Annotations + +The following annotations are deprecated, and only listed here to aid in +migrating away from them. + +### backstage.io/github-actions-id + +This annotation was used for a while to enable the GitHub Actions feature. This +is now instead using the [github.com/project-slug](#github-com-project-slug) +annotation, with the same value format. + +## Links + +- [Descriptor Format: annotations](descriptor-format.md#annotations-optional) diff --git a/packages/app/package.json b/packages/app/package.json index 1affbe4d09..77f5ab4432 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -11,6 +11,7 @@ "@backstage/plugin-catalog": "^0.1.1-alpha.21", "@backstage/plugin-circleci": "^0.1.1-alpha.21", "@backstage/plugin-explore": "^0.1.1-alpha.21", + "@backstage/plugin-gcp-projects": "^0.1.1-alpha.21", "@backstage/plugin-github-actions": "^0.1.1-alpha.21", "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.21", "@backstage/plugin-graphiql": "^0.1.1-alpha.21", diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 15bf6ffd76..b391e95d62 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -68,6 +68,7 @@ import { } from '@backstage/plugin-techdocs'; import { rollbarApiRef, RollbarClient } from '@backstage/plugin-rollbar'; +import { GCPClient, GCPApiRef } from '@backstage/plugin-gcp-projects'; import { GithubActionsClient, githubActionsApiRef, @@ -103,6 +104,7 @@ export const apis = (config: ConfigApi) => { ); builder.add(storageApiRef, WebStorage.create({ errorApi })); + builder.add(GCPApiRef, new GCPClient()); builder.add( circleCIApiRef, new CircleCIApi(`${backendUrl}/proxy/circleci/api`), diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 879e2fe6f7..cc1ae8bedc 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ import { Router as GitHubActionsRouter } from '@backstage/plugin-github-actions'; +import { Router as SentryRouter } from '@backstage/plugin-sentry'; import React from 'react'; import { EntityPageLayout, @@ -43,6 +44,11 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( title="CI/CD" element={} /> + } + /> ); @@ -58,6 +64,11 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( title="CI/CD" element={} /> + } + /> ); diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index f338c169b7..b36d0323e0 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -32,3 +32,4 @@ export { plugin as TravisCI } from '@roadiehq/backstage-plugin-travis-ci'; export { plugin as Jenkins } from '@backstage/plugin-jenkins'; export { plugin as ApiDocs } from '@backstage/plugin-api-docs'; export { plugin as GithubPullRequests } from '@roadiehq/backstage-plugin-github-pull-requests'; +export { plugin as GcpProjects } from '@backstage/plugin-gcp-projects'; diff --git a/packages/core/src/layout/Page/Page.stories.tsx b/packages/core/src/layout/Page/Page.stories.tsx index 786b4740fd..aec99967dc 100644 --- a/packages/core/src/layout/Page/Page.stories.tsx +++ b/packages/core/src/layout/Page/Page.stories.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import { Header, Page, @@ -22,9 +22,18 @@ import { ContentHeader, Content, pageTheme, + InfoCard, + HeaderTabs, } from '../'; -import { SupportButton, Table, StatusOK, TableColumn } from '../../components'; -import { Box, Typography, Link, Chip, Button } from '@material-ui/core'; +import { + SupportButton, + Table, + StatusOK, + TableColumn, + ProgressCard, + TrendLine, +} from '../../components'; +import { Box, Typography, Link, Chip, Grid } from '@material-ui/core'; export default { title: 'Example Plugin', @@ -85,23 +94,131 @@ const columns: TableColumn[] = [ }, ]; +const tabs = [ + { label: 'Overview' }, + { label: 'CI/CD' }, + { label: 'Cost Efficency' }, + { label: 'Code Coverage' }, + { label: 'Test' }, + { label: 'Compliance Advisor' }, +]; + +const DataGrid = () => ( + + + + + + + + + + + + + + + + + + + Rightsize GKE deployment +

+ Services are considered underutilized in GKE when the average usage of + requested cores is less than 80%. +

+ What can I do? +

+ Review requested core and limit settings. Check HPA target scaling + settings in hpa.yaml. The recommended value for + targetCPUUtilizationPercentage is 80. +

+

+ For single pods, there is of course no HPA. But it can also be useful + to think about a single pod out of a larger deployment, then modify + based on HPA requirements. Within a pod, each container has its own + CPU and memory requests and limits. +

+ Definitions +

+ A request is a minimum reserved value; a container will never have + less than this amount allocated to it, even if it doesn't actually use + it. Requests are used for determining what nodes to schedule pods on + (bin-packing). The tension here is between not allocating resources we + don't need, and having easy-enough access to enough resources to be + able to function. +

+ + Contact #cost-awareness for information and support. + +
+
+
+); + +const ExampleHeader = () => ( +
+ + +
+); + +const ExampleContentHeader = ({ selectedTab }: { selectedTab?: number }) => ( + + + This Plugin is an example. This text could provide usefull information for + the user. + + +); + +export const PluginWithData = () => { + const [selectedTab, setSelectedTab] = useState(2); + return ( + + + setSelectedTab(index)} + tabs={tabs.map(({ label }, index) => ({ + id: index.toString(), + label, + }))} + /> + + + + + + ); +}; + export const PluginWithTable = () => { return ( -
- - -
+ - - - - This Plugin is an example. This text could provide usefull - information for the user. - - + { + describe('postMessageResponse', () => { + const appOrigin = 'http://localhost:3000'; + it('should post a message back with payload success', () => { + const mockResponse = ({ + end: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + const data: WebMessageResponse = { + type: 'authorization_response', + response: { + providerInfo: { + accessToken: 'ACCESS_TOKEN', + idToken: 'ID_TOKEN', + expiresInSeconds: 10, + scope: 'email', + }, + profile: { + email: 'foo@bar.com', + }, + backstageIdentity: { + id: 'a', + idToken: 'a.b.c', + }, + }, + }; + const jsonData = JSON.stringify(data); + const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); + + postMessageResponse(mockResponse, appOrigin, data); + expect(mockResponse.setHeader).toBeCalledTimes(3); + expect(mockResponse.end).toBeCalledTimes(1); + expect(mockResponse.end).toBeCalledWith( + expect.stringContaining(base64Data), + ); + }); + + it('should post a message back with payload error', () => { + const mockResponse = ({ + end: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + const data: WebMessageResponse = { + type: 'authorization_response', + error: new Error('Unknown error occured'), + }; + const jsonData = JSON.stringify(data); + const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); + + postMessageResponse(mockResponse, appOrigin, data); + expect(mockResponse.setHeader).toBeCalledTimes(3); + expect(mockResponse.end).toBeCalledTimes(1); + expect(mockResponse.end).toBeCalledWith( + expect.stringContaining(base64Data), + ); + }); + }); + + describe('ensuresXRequestedWith', () => { + it('should return false if no header present', () => { + const mockRequest = ({ + header: () => jest.fn(), + } as unknown) as express.Request; + expect(ensuresXRequestedWith(mockRequest)).toBe(false); + }); + + it('should return false if header present with incorrect value', () => { + const mockRequest = ({ + header: () => 'INVALID', + } as unknown) as express.Request; + expect(ensuresXRequestedWith(mockRequest)).toBe(false); + }); + + it('should return true if header present with correct value', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + } as unknown) as express.Request; + expect(ensuresXRequestedWith(mockRequest)).toBe(true); + }); + }); +}); diff --git a/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts b/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts new file mode 100644 index 0000000000..63d7c28b50 --- /dev/null +++ b/plugins/auth-backend/src/lib/flow/authFlowHelpers.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import crypto from 'crypto'; +import { WebMessageResponse } from './types'; + +export const postMessageResponse = ( + res: express.Response, + appOrigin: string, + response: WebMessageResponse, +) => { + const jsonData = JSON.stringify(response); + const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); + + res.setHeader('Content-Type', 'text/html'); + res.setHeader('X-Frame-Options', 'sameorigin'); + + // TODO: Make target app origin configurable globally + const script = ` + (window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}') + window.close() + `; + const hash = crypto.createHash('sha256').update(script).digest('base64'); + res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`); + + res.end(` + + + + + + `); +}; + +export const ensuresXRequestedWith = (req: express.Request) => { + const requiredHeader = req.header('X-Requested-With'); + + if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') { + return false; + } + return true; +}; diff --git a/plugins/auth-backend/src/lib/flow/index.ts b/plugins/auth-backend/src/lib/flow/index.ts new file mode 100644 index 0000000000..a5f2f7a3ac --- /dev/null +++ b/plugins/auth-backend/src/lib/flow/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers'; diff --git a/plugins/auth-backend/src/lib/flow/types.ts b/plugins/auth-backend/src/lib/flow/types.ts new file mode 100644 index 0000000000..98bb551c2c --- /dev/null +++ b/plugins/auth-backend/src/lib/flow/types.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthResponse } from '../../providers/types'; + +/** + * Payload sent as a post message after the auth request is complete. + * If successful then has a valid payload with Auth information else contains an error. + */ +export type WebMessageResponse = + | { + type: 'authorization_response'; + response: AuthResponse; + } + | { + type: 'authorization_response'; + error: Error; + }; diff --git a/plugins/auth-backend/src/lib/OAuthProvider.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts similarity index 56% rename from plugins/auth-backend/src/lib/OAuthProvider.test.ts rename to plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index 06bbd15c51..d2b31213f2 100644 --- a/plugins/auth-backend/src/lib/OAuthProvider.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -15,16 +15,9 @@ */ import express from 'express'; -import { - ensuresXRequestedWith, - postMessageResponse, - THOUSAND_DAYS_MS, - TEN_MINUTES_MS, - verifyNonce, - encodeState, - OAuthProvider, -} from './OAuthProvider'; -import { WebMessageResponse, OAuthProviderHandlers } from '../providers/types'; +import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter'; +import { encodeState } from './helpers'; +import { OAuthHandlers } from './types'; const mockResponseData = { providerInfo: { @@ -41,149 +34,8 @@ const mockResponseData = { }, }; -describe('OAuthProvider Utils', () => { - describe('verifyNonce', () => { - it('should throw error if cookie nonce missing', () => { - const state = { nonce: 'NONCE', env: 'development' }; - const mockRequest = ({ - cookies: {}, - query: { - state: encodeState(state), - }, - } as unknown) as express.Request; - expect(() => { - verifyNonce(mockRequest, 'providera'); - }).toThrowError('Auth response is missing cookie nonce'); - }); - - it('should throw error if state nonce missing', () => { - const mockRequest = ({ - cookies: { - 'providera-nonce': 'NONCE', - }, - query: {}, - } as unknown) as express.Request; - expect(() => { - verifyNonce(mockRequest, 'providera'); - }).toThrowError('Invalid state passed via request'); - }); - - it('should throw error if nonce mismatch', () => { - const state = { nonce: 'NONCEB', env: 'development' }; - const mockRequest = ({ - cookies: { - 'providera-nonce': 'NONCEA', - }, - query: { - state: encodeState(state), - }, - } as unknown) as express.Request; - expect(() => { - verifyNonce(mockRequest, 'providera'); - }).toThrowError('Invalid nonce'); - }); - - it('should not throw any error if nonce matches', () => { - const state = { nonce: 'NONCE', env: 'development' }; - const mockRequest = ({ - cookies: { - 'providera-nonce': 'NONCE', - }, - query: { - state: encodeState(state), - }, - } as unknown) as express.Request; - expect(() => { - verifyNonce(mockRequest, 'providera'); - }).not.toThrow(); - }); - }); - - describe('postMessageResponse', () => { - const appOrigin = 'http://localhost:3000'; - it('should post a message back with payload success', () => { - const mockResponse = ({ - end: jest.fn().mockReturnThis(), - setHeader: jest.fn().mockReturnThis(), - } as unknown) as express.Response; - - const data: WebMessageResponse = { - type: 'authorization_response', - response: { - providerInfo: { - accessToken: 'ACCESS_TOKEN', - idToken: 'ID_TOKEN', - expiresInSeconds: 10, - scope: 'email', - }, - profile: { - email: 'foo@bar.com', - }, - backstageIdentity: { - id: 'a', - idToken: 'a.b.c', - }, - }, - }; - const jsonData = JSON.stringify(data); - const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); - - postMessageResponse(mockResponse, appOrigin, data); - expect(mockResponse.setHeader).toBeCalledTimes(3); - expect(mockResponse.end).toBeCalledTimes(1); - expect(mockResponse.end).toBeCalledWith( - expect.stringContaining(base64Data), - ); - }); - - it('should post a message back with payload error', () => { - const mockResponse = ({ - end: jest.fn().mockReturnThis(), - setHeader: jest.fn().mockReturnThis(), - } as unknown) as express.Response; - - const data: WebMessageResponse = { - type: 'authorization_response', - error: new Error('Unknown error occured'), - }; - const jsonData = JSON.stringify(data); - const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); - - postMessageResponse(mockResponse, appOrigin, data); - expect(mockResponse.setHeader).toBeCalledTimes(3); - expect(mockResponse.end).toBeCalledTimes(1); - expect(mockResponse.end).toBeCalledWith( - expect.stringContaining(base64Data), - ); - }); - }); - - describe('ensuresXRequestedWith', () => { - it('should return false if no header present', () => { - const mockRequest = ({ - header: () => jest.fn(), - } as unknown) as express.Request; - expect(ensuresXRequestedWith(mockRequest)).toBe(false); - }); - - it('should return false if header present with incorrect value', () => { - const mockRequest = ({ - header: () => 'INVALID', - } as unknown) as express.Request; - expect(ensuresXRequestedWith(mockRequest)).toBe(false); - }); - - it('should return true if header present with correct value', () => { - const mockRequest = ({ - header: () => 'XMLHttpRequest', - } as unknown) as express.Request; - expect(ensuresXRequestedWith(mockRequest)).toBe(true); - }); - }); -}); - -describe('OAuthProvider', () => { - class MyAuthProvider implements OAuthProviderHandlers { +describe('OAuthAdapter', () => { + class MyAuthProvider implements OAuthHandlers { async start() { return { url: '/url', @@ -215,7 +67,7 @@ describe('OAuthProvider', () => { }; it('sets the correct headers in start', async () => { - const oauthProvider = new OAuthProvider( + const oauthProvider = new OAuthAdapter( providerInstance, oAuthProviderOptions, ); @@ -250,7 +102,7 @@ describe('OAuthProvider', () => { }); it('sets the refresh cookie if refresh is enabled', async () => { - const oauthProvider = new OAuthProvider(providerInstance, { + const oauthProvider = new OAuthAdapter(providerInstance, { ...oAuthProviderOptions, disableRefresh: false, }); @@ -284,7 +136,7 @@ describe('OAuthProvider', () => { }); it('does not set the refresh cookie if refresh is disabled', async () => { - const oauthProvider = new OAuthProvider(providerInstance, { + const oauthProvider = new OAuthAdapter(providerInstance, { ...oAuthProviderOptions, disableRefresh: true, }); @@ -309,7 +161,7 @@ describe('OAuthProvider', () => { }); it('removes refresh cookie when logging out', async () => { - const oauthProvider = new OAuthProvider(providerInstance, { + const oauthProvider = new OAuthAdapter(providerInstance, { ...oAuthProviderOptions, disableRefresh: false, }); @@ -334,7 +186,7 @@ describe('OAuthProvider', () => { it('gets new access-token when refreshing', async () => { oAuthProviderOptions.disableRefresh = false; - const oauthProvider = new OAuthProvider(providerInstance, { + const oauthProvider = new OAuthAdapter(providerInstance, { ...oAuthProviderOptions, disableRefresh: false, }); @@ -363,7 +215,7 @@ describe('OAuthProvider', () => { }); it('handles refresh without capabilities', async () => { - const oauthProvider = new OAuthProvider(providerInstance, { + const oauthProvider = new OAuthAdapter(providerInstance, { ...oAuthProviderOptions, disableRefresh: true, }); diff --git a/plugins/auth-backend/src/lib/OAuthProvider.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts similarity index 69% rename from plugins/auth-backend/src/lib/OAuthProvider.ts rename to plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index dac31b442d..0f092ae780 100644 --- a/plugins/auth-backend/src/lib/OAuthProvider.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -19,14 +19,14 @@ import crypto from 'crypto'; import { URL } from 'url'; import { AuthProviderRouteHandlers, - OAuthProviderHandlers, - WebMessageResponse, BackstageIdentity, - OAuthState, AuthProviderConfig, -} from '../providers/types'; +} from '../../providers/types'; import { InputError } from '@backstage/backend-common'; -import { TokenIssuer } from '../identity'; +import { TokenIssuer } from '../../identity'; +import { verifyNonce, encodeState } from './helpers'; +import { postMessageResponse, ensuresXRequestedWith } from '../flow'; +import { OAuthHandlers } from './types'; export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000; export const TEN_MINUTES_MS = 600 * 1000; @@ -42,99 +42,20 @@ export type Options = { tokenIssuer: TokenIssuer; }; -const readState = (stateString: string): OAuthState => { - const state = Object.fromEntries( - new URLSearchParams(decodeURIComponent(stateString)), - ); - if ( - !state.nonce || - !state.env || - state.nonce?.length === 0 || - state.env?.length === 0 - ) { - throw Error(`Invalid state passed via request`); - } - return { - nonce: state.nonce, - env: state.env, - }; -}; - -export const encodeState = (state: OAuthState): string => { - const searchParams = new URLSearchParams(); - searchParams.append('nonce', state.nonce); - searchParams.append('env', state.env); - - return encodeURIComponent(searchParams.toString()); -}; - -export const verifyNonce = (req: express.Request, providerId: string) => { - const cookieNonce = req.cookies[`${providerId}-nonce`]; - const state: OAuthState = readState(req.query.state?.toString() ?? ''); - const stateNonce = state.nonce; - - if (!cookieNonce) { - throw new Error('Auth response is missing cookie nonce'); - } - if (stateNonce.length === 0) { - throw new Error('Auth response is missing state nonce'); - } - if (cookieNonce !== stateNonce) { - throw new Error('Invalid nonce'); - } -}; - -export const postMessageResponse = ( - res: express.Response, - appOrigin: string, - response: WebMessageResponse, -) => { - const jsonData = JSON.stringify(response); - const base64Data = Buffer.from(jsonData, 'utf8').toString('base64'); - - res.setHeader('Content-Type', 'text/html'); - res.setHeader('X-Frame-Options', 'sameorigin'); - - // TODO: Make target app origin configurable globally - const script = ` - (window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}') - window.close() - `; - const hash = crypto.createHash('sha256').update(script).digest('base64'); - res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`); - - res.end(` - - - - - - `); -}; - -export const ensuresXRequestedWith = (req: express.Request) => { - const requiredHeader = req.header('X-Requested-With'); - - if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') { - return false; - } - return true; -}; - -export class OAuthProvider implements AuthProviderRouteHandlers { +export class OAuthAdapter implements AuthProviderRouteHandlers { static fromConfig( config: AuthProviderConfig, - providerHandlers: OAuthProviderHandlers, + handlers: OAuthHandlers, options: Pick< Options, 'providerId' | 'persistScopes' | 'disableRefresh' | 'tokenIssuer' >, - ): OAuthProvider { + ): OAuthAdapter { const { origin: appOrigin } = new URL(config.appUrl); const secure = config.baseUrl.startsWith('https://'); const url = new URL(config.baseUrl); const cookiePath = `${url.pathname}/${options.providerId}`; - return new OAuthProvider(providerHandlers, { + return new OAuthAdapter(handlers, { ...options, appOrigin, cookieDomain: url.hostname, @@ -144,7 +65,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers { } constructor( - private readonly providerHandlers: OAuthProviderHandlers, + private readonly handlers: OAuthHandlers, private readonly options: Options, ) {} @@ -173,10 +94,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers { state: stateParameter, }; - const { url, status } = await this.providerHandlers.start( - req, - queryParameters, - ); + const { url, status } = await this.handlers.start(req, queryParameters); res.statusCode = status || 302; res.setHeader('Location', url); @@ -192,9 +110,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers { // verify nonce cookie and state cookie on callback verifyNonce(req, this.options.providerId); - const { response, refreshToken } = await this.providerHandlers.handler( - req, - ); + const { response, refreshToken } = await this.handlers.handler(req); if (this.options.persistScopes) { const grantedScopes = this.getScopesFromCookie( @@ -251,7 +167,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers { return; } - if (!this.providerHandlers.refresh || this.options.disableRefresh) { + if (!this.handlers.refresh || this.options.disableRefresh) { res.send( `Refresh token not supported for provider: ${this.options.providerId}`, ); @@ -270,7 +186,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers { const scope = req.query.scope?.toString() ?? ''; // get new access_token - const response = await this.providerHandlers.refresh(refreshToken, scope); + const response = await this.handlers.refresh(refreshToken, scope); await this.populateIdentity(response.backstageIdentity); @@ -287,19 +203,6 @@ export class OAuthProvider implements AuthProviderRouteHandlers { } } - identifyEnv(req: express.Request): string | undefined { - const reqEnv = req.query.env?.toString(); - if (reqEnv) { - return reqEnv; - } - const stateParams = req.query.state?.toString(); - if (!stateParams) { - return undefined; - } - const env = readState(stateParams).env; - return env; - } - /** * If the response from the OAuth provider includes a Backstage identity, we * make sure it's populated with all the information we can derive from the user ID. diff --git a/plugins/auth-backend/src/lib/EnvironmentHandler.ts b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts similarity index 54% rename from plugins/auth-backend/src/lib/EnvironmentHandler.ts rename to plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts index 53e56a7420..d22fc52499 100644 --- a/plugins/auth-backend/src/lib/EnvironmentHandler.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthEnvironmentHandler.ts @@ -15,46 +15,32 @@ */ import express from 'express'; -import { - AuthProviderRouteHandlers, - EnvironmentIdentifierFn, -} from '../providers/types'; +import { Config } from '@backstage/config'; import { InputError } from '@backstage/backend-common'; +import { readState } from './helpers'; +import { AuthProviderRouteHandlers } from '../../providers/types'; -export type EnvironmentHandlers = { - [key: string]: AuthProviderRouteHandlers; -}; +export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers { + static mapConfig( + config: Config, + factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers, + ) { + const envs = config.keys(); + const handlers = new Map(); -export class EnvironmentHandler implements AuthProviderRouteHandlers { - constructor( - private readonly providerId: string, - private readonly providers: EnvironmentHandlers, - private readonly envIdentifier: EnvironmentIdentifierFn, - ) {} - - private getProviderForEnv( - req: express.Request, - res: express.Response, - ): AuthProviderRouteHandlers | undefined { - const env: string | undefined = this.envIdentifier(req); - - if (!env) { - throw new InputError(`Must specify 'env' query to select environment`); + for (const env of envs) { + const envConfig = config.getConfig(env); + const handler = factoryFunc(envConfig); + handlers.set(env, handler); } - if (this.providers.hasOwnProperty(env)) { - return this.providers[env]; - } - - res.status(404).send( - `Missing configuration. -
-
-For this flow to work you need to supply a valid configuration for the "${env}" environment of the "${this.providerId}" provider.`, - ); - return undefined; + return new OAuthEnvironmentHandler(handlers); } + constructor( + private readonly handlers: Map, + ) {} + async start(req: express.Request, res: express.Response): Promise { const provider = this.getProviderForEnv(req, res); await provider?.start(req, res); @@ -77,4 +63,40 @@ For this flow to work you need to supply a valid configuration for the "${env}" const provider = this.getProviderForEnv(req, res); await provider?.logout?.(req, res); } + + private getRequestFromEnv(req: express.Request): string | undefined { + const reqEnv = req.query.env?.toString(); + if (reqEnv) { + return reqEnv; + } + const stateParams = req.query.state?.toString(); + if (!stateParams) { + return undefined; + } + const env = readState(stateParams).env; + return env; + } + + private getProviderForEnv( + req: express.Request, + res: express.Response, + ): AuthProviderRouteHandlers | undefined { + const env: string | undefined = this.getRequestFromEnv(req); + + if (!env) { + throw new InputError(`Must specify 'env' query to select environment`); + } + + if (!this.handlers.has(env)) { + res.status(404).send( + `Missing configuration. +
+
+ For this flow to work you need to supply a valid configuration for the "${env}" environment of provider.`, + ); + return undefined; + } + + return this.handlers.get(env); + } } diff --git a/plugins/auth-backend/src/lib/oauth/helpers.test.ts b/plugins/auth-backend/src/lib/oauth/helpers.test.ts new file mode 100644 index 0000000000..fcf56705d2 --- /dev/null +++ b/plugins/auth-backend/src/lib/oauth/helpers.test.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import { verifyNonce, encodeState } from './helpers'; + +describe('OAuthProvider Utils', () => { + describe('verifyNonce', () => { + it('should throw error if cookie nonce missing', () => { + const state = { nonce: 'NONCE', env: 'development' }; + const mockRequest = ({ + cookies: {}, + query: { + state: encodeState(state), + }, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).toThrowError('Auth response is missing cookie nonce'); + }); + + it('should throw error if state nonce missing', () => { + const mockRequest = ({ + cookies: { + 'providera-nonce': 'NONCE', + }, + query: {}, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).toThrowError('Invalid state passed via request'); + }); + + it('should throw error if nonce mismatch', () => { + const state = { nonce: 'NONCEB', env: 'development' }; + const mockRequest = ({ + cookies: { + 'providera-nonce': 'NONCEA', + }, + query: { + state: encodeState(state), + }, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).toThrowError('Invalid nonce'); + }); + + it('should not throw any error if nonce matches', () => { + const state = { nonce: 'NONCE', env: 'development' }; + const mockRequest = ({ + cookies: { + 'providera-nonce': 'NONCE', + }, + query: { + state: encodeState(state), + }, + } as unknown) as express.Request; + expect(() => { + verifyNonce(mockRequest, 'providera'); + }).not.toThrow(); + }); + }); +}); diff --git a/plugins/auth-backend/src/lib/oauth/helpers.ts b/plugins/auth-backend/src/lib/oauth/helpers.ts new file mode 100644 index 0000000000..9f250a0285 --- /dev/null +++ b/plugins/auth-backend/src/lib/oauth/helpers.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import { OAuthState } from './types'; + +export const readState = (stateString: string): OAuthState => { + const state = Object.fromEntries( + new URLSearchParams(decodeURIComponent(stateString)), + ); + if ( + !state.nonce || + !state.env || + state.nonce?.length === 0 || + state.env?.length === 0 + ) { + throw Error(`Invalid state passed via request`); + } + return { + nonce: state.nonce, + env: state.env, + }; +}; + +export const encodeState = (state: OAuthState): string => { + const searchParams = new URLSearchParams(); + searchParams.append('nonce', state.nonce); + searchParams.append('env', state.env); + + return encodeURIComponent(searchParams.toString()); +}; + +export const verifyNonce = (req: express.Request, providerId: string) => { + const cookieNonce = req.cookies[`${providerId}-nonce`]; + const state: OAuthState = readState(req.query.state?.toString() ?? ''); + const stateNonce = state.nonce; + + if (!cookieNonce) { + throw new Error('Auth response is missing cookie nonce'); + } + if (stateNonce.length === 0) { + throw new Error('Auth response is missing state nonce'); + } + if (cookieNonce !== stateNonce) { + throw new Error('Invalid nonce'); + } +}; diff --git a/plugins/auth-backend/src/lib/oauth/index.ts b/plugins/auth-backend/src/lib/oauth/index.ts new file mode 100644 index 0000000000..05c8bd9d3d --- /dev/null +++ b/plugins/auth-backend/src/lib/oauth/index.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler'; +export { OAuthAdapter } from './OAuthAdapter'; +export type { + OAuthHandlers, + OAuthProviderInfo, + OAuthProviderOptions, + OAuthResponse, + OAuthState, +} from './types'; diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts new file mode 100644 index 0000000000..a854326bed --- /dev/null +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import { AuthResponse, RedirectInfo } from '../../providers/types'; + +/** + * Common options for passport.js-based OAuth providers + */ +export type OAuthProviderOptions = { + /** + * Client ID of the auth provider. + */ + clientId: string; + /** + * Client Secret of the auth provider. + */ + clientSecret: string; + /** + * Callback URL to be passed to the auth provider to redirect to after the user signs in. + */ + callbackUrl: string; +}; + +export type OAuthResponse = AuthResponse; + +export type OAuthProviderInfo = { + /** + * An access token issued for the signed in user. + */ + accessToken: string; + /** + * (Optional) Id token issued for the signed in user. + */ + idToken?: string; + /** + * Expiry of the access token in seconds. + */ + expiresInSeconds?: number; + /** + * Scopes granted for the access token. + */ + scope: string; + /** + * A refresh token issued for the signed in user + */ + refreshToken?: string; +}; + +export type OAuthState = { + /* A type for the serialized value in the `state` parameter of the OAuth authorization flow + */ + nonce: string; + env: string; +}; + +/** + * Any OAuth provider needs to implement this interface which has provider specific + * handlers for different methods to perform authentication, get access tokens, + * refresh tokens and perform sign out. + */ +export interface OAuthHandlers { + /** + * This method initiates a sign in request with an auth provider. + * @param {express.Request} req + * @param options + */ + start( + req: express.Request, + options: Record, + ): Promise; + + /** + * Handles the redirect from the auth provider when the user has signed in. + * @param {express.Request} req + */ + handler( + req: express.Request, + ): Promise<{ + response: AuthResponse; + refreshToken?: string; + }>; + + /** + * (Optional) Given a refresh token and scope fetches a new access token from the auth provider. + * @param {string} refreshToken + * @param {string} scope + */ + refresh?( + refreshToken: string, + scope: string, + ): Promise>; + + /** + * (Optional) Sign out of the auth provider. + */ + logout?(): Promise; +} diff --git a/plugins/auth-backend/src/lib/PassportStrategyHelper.test.ts b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts similarity index 100% rename from plugins/auth-backend/src/lib/PassportStrategyHelper.test.ts rename to plugins/auth-backend/src/lib/passport/PassportStrategyHelper.test.ts diff --git a/plugins/auth-backend/src/lib/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts similarity index 90% rename from plugins/auth-backend/src/lib/PassportStrategyHelper.ts rename to plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts index 9a169f9696..7bc34186f4 100644 --- a/plugins/auth-backend/src/lib/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/lib/passport/PassportStrategyHelper.ts @@ -17,12 +17,13 @@ import express from 'express'; import passport from 'passport'; import jwtDecoder from 'jwt-decode'; -import { - RedirectInfo, - RefreshTokenResponse, - ProfileInfo, - ProviderStrategy, -} from '../providers/types'; +import { ProfileInfo, RedirectInfo } from '../../providers/types'; + +export type PassportDoneCallback = ( + err?: Error, + response?: Res, + privateInfo?: Private, +) => void; export const makeProfileInfo = ( profile: passport.Profile, @@ -106,6 +107,18 @@ export const executeFrameHandlerStrategy = async ( ); }; +type RefreshTokenResponse = { + /** + * An access token issued for the signed in user. + */ + accessToken: string; + /** + * Optionally, the server can issue a new Refresh Token for the user + */ + refreshToken?: string; + params: any; +}; + export const executeRefreshTokenStrategy = async ( providerStrategy: passport.Strategy, refreshToken: string, @@ -156,6 +169,10 @@ export const executeRefreshTokenStrategy = async ( }); }; +type ProviderStrategy = { + userProfile(accessToken: string, callback: Function): void; +}; + export const executeFetchUserProfileStrategy = async ( providerStrategy: passport.Strategy, accessToken: string, diff --git a/plugins/auth-backend/src/lib/passport/index.ts b/plugins/auth-backend/src/lib/passport/index.ts new file mode 100644 index 0000000000..c307e212fa --- /dev/null +++ b/plugins/auth-backend/src/lib/passport/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + executeFetchUserProfileStrategy, + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, +} from './PassportStrategyHelper'; +export type { PassportDoneCallback } from './PassportStrategyHelper'; diff --git a/plugins/auth-backend/src/providers/auth0/provider.ts b/plugins/auth-backend/src/providers/auth0/provider.ts index 7eeca59028..3b7817ffe9 100644 --- a/plugins/auth-backend/src/providers/auth0/provider.ts +++ b/plugins/auth-backend/src/providers/auth0/provider.ts @@ -17,25 +17,22 @@ import express from 'express'; import passport from 'passport'; import Auth0Strategy from './strategy'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity'; -import { OAuthProvider } from '../../lib/OAuthProvider'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, +} from '../../lib/oauth'; import { executeFetchUserProfileStrategy, executeFrameHandlerStrategy, executeRedirectStrategy, executeRefreshTokenStrategy, makeProfileInfo, -} from '../../lib/PassportStrategyHelper'; -import { - AuthProviderConfig, - OAuthProviderHandlers, - OAuthResponse, PassportDoneCallback, - RedirectInfo, - OAuthProviderOptions, -} from '../types'; -import { Config } from '@backstage/config'; +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; type PrivateInfo = { refreshToken: string; @@ -45,7 +42,7 @@ export type Auth0AuthProviderOptions = OAuthProviderOptions & { domain: string; }; -export class Auth0AuthProvider implements OAuthProviderHandlers { +export class Auth0AuthProvider implements OAuthHandlers { private readonly _strategy: Auth0Strategy; constructor(options: Auth0AuthProviderOptions) { @@ -151,29 +148,28 @@ export class Auth0AuthProvider implements OAuthProviderHandlers { } } -export function createAuth0Provider( - config: AuthProviderConfig, - _: string, - envConfig: Config, - _logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'auth0'; - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const domain = envConfig.getString('domain'); - const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`; +export const createAuth0Provider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'auth0'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const domain = envConfig.getString('domain'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const provider = new Auth0AuthProvider({ - clientId, - clientSecret, - callbackUrl, - domain, - }); + const provider = new Auth0AuthProvider({ + clientId, + clientSecret, + callbackUrl, + domain, + }); - return OAuthProvider.fromConfig(config, provider, { - disableRefresh: true, - providerId, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: true, + providerId, + tokenIssuer, + }); }); -} diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 26989d8759..6c83dffda3 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -25,16 +25,8 @@ import { createOktaProvider } from './okta'; import { createSamlProvider } from './saml'; import { createAuth0Provider } from './auth0'; import { createMicrosoftProvider } from './microsoft'; -import { - AuthProviderConfig, - AuthProviderFactory, - EnvironmentIdentifierFn, -} from './types'; +import { AuthProviderConfig, AuthProviderFactory } from './types'; import { Config } from '@backstage/config'; -import { - EnvironmentHandlers, - EnvironmentHandler, -} from '../lib/EnvironmentHandler'; const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, @@ -50,9 +42,9 @@ const factories: { [providerId: string]: AuthProviderFactory } = { export const createAuthProviderRouter = ( providerId: string, globalConfig: AuthProviderConfig, - providerConfig: Config, + config: Config, logger: Logger, - issuer: TokenIssuer, + tokenIssuer: TokenIssuer, ) => { const factory = factories[providerId]; if (!factory) { @@ -60,28 +52,8 @@ export const createAuthProviderRouter = ( } const router = Router(); - const envs = providerConfig.keys(); - const envProviders: EnvironmentHandlers = {}; - let envIdentifier: EnvironmentIdentifierFn | undefined; - for (const env of envs) { - const envConfig = providerConfig.getConfig(env); - const provider = factory(globalConfig, env, envConfig, logger, issuer); - if (provider) { - envProviders[env] = provider; - envIdentifier = provider.identifyEnv; - } - } - - if (typeof envIdentifier === 'undefined') { - throw Error(`No envIdentifier provided for '${providerId}'`); - } - - const handler = new EnvironmentHandler( - providerId, - envProviders, - envIdentifier, - ); + const handler = factory({ globalConfig, config, logger, tokenIssuer }); router.get('/start', handler.start.bind(handler)); router.get('/handler/frame', handler.frameHandler.bind(handler)); diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index b63acdfb20..2205fb6794 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -20,20 +20,17 @@ import { executeFrameHandlerStrategy, executeRedirectStrategy, makeProfileInfo, -} from '../../lib/PassportStrategyHelper'; -import { - OAuthProviderHandlers, - AuthProviderConfig, - RedirectInfo, - OAuthProviderOptions, - OAuthResponse, PassportDoneCallback, -} from '../types'; -import { OAuthProvider } from '../../lib/OAuthProvider'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity'; +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, +} from '../../lib/oauth'; import passport from 'passport'; -import { Config } from '@backstage/config'; export type GithubAuthProviderOptions = OAuthProviderOptions & { tokenUrl?: string; @@ -41,7 +38,7 @@ export type GithubAuthProviderOptions = OAuthProviderOptions & { authorizationUrl?: string; }; -export class GithubAuthProvider implements OAuthProviderHandlers { +export class GithubAuthProvider implements OAuthHandlers { private readonly _strategy: GithubStrategy; static transformPassportProfile(rawProfile: any): passport.Profile { @@ -137,43 +134,42 @@ export class GithubAuthProvider implements OAuthProviderHandlers { } } -export function createGithubProvider( - config: AuthProviderConfig, - _: string, - envConfig: Config, - _logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'github'; - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const enterpriseInstanceUrl = envConfig.getOptionalString( - 'enterpriseInstanceUrl', - ); - const authorizationUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/login/oauth/authorize` - : undefined; - const tokenUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/login/oauth/access_token` - : undefined; - const userProfileUrl = enterpriseInstanceUrl - ? `${enterpriseInstanceUrl}/api/v3/user` - : undefined; - const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`; +export const createGithubProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'github'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const enterpriseInstanceUrl = envConfig.getOptionalString( + 'enterpriseInstanceUrl', + ); + const authorizationUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/authorize` + : undefined; + const tokenUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/login/oauth/access_token` + : undefined; + const userProfileUrl = enterpriseInstanceUrl + ? `${enterpriseInstanceUrl}/api/v3/user` + : undefined; + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const provider = new GithubAuthProvider({ - clientId, - clientSecret, - callbackUrl, - tokenUrl, - userProfileUrl, - authorizationUrl, - }); + const provider = new GithubAuthProvider({ + clientId, + clientSecret, + callbackUrl, + tokenUrl, + userProfileUrl, + authorizationUrl, + }); - return OAuthProvider.fromConfig(config, provider, { - disableRefresh: true, - persistScopes: true, - providerId, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: true, + persistScopes: true, + providerId, + tokenIssuer, + }); }); -} diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts index 1c2a822d82..97f0b2bd22 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -20,26 +20,23 @@ import { executeFrameHandlerStrategy, executeRedirectStrategy, makeProfileInfo, -} from '../../lib/PassportStrategyHelper'; -import { - OAuthProviderHandlers, - AuthProviderConfig, - RedirectInfo, - OAuthProviderOptions, - OAuthResponse, PassportDoneCallback, -} from '../types'; -import { OAuthProvider } from '../../lib/OAuthProvider'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity'; +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, +} from '../../lib/oauth'; import passport from 'passport'; -import { Config } from '@backstage/config'; export type GitlabAuthProviderOptions = OAuthProviderOptions & { baseUrl: string; }; -export class GitlabAuthProvider implements OAuthProviderHandlers { +export class GitlabAuthProvider implements OAuthHandlers { private readonly _strategy: GitlabStrategy; static transformPassportProfile(rawProfile: any): passport.Profile { @@ -140,30 +137,29 @@ export class GitlabAuthProvider implements OAuthProviderHandlers { } } -export function createGitlabProvider( - config: AuthProviderConfig, - _: string, - envConfig: Config, - _logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'gitlab'; - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const audience = envConfig.getString('audience'); - const baseUrl = audience || 'https://gitlab.com'; - const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`; +export const createGitlabProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'gitlab'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getString('audience'); + const baseUrl = audience || 'https://gitlab.com'; + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const provider = new GitlabAuthProvider({ - clientId, - clientSecret, - callbackUrl, - baseUrl, - }); + const provider = new GitlabAuthProvider({ + clientId, + clientSecret, + callbackUrl, + baseUrl, + }); - return OAuthProvider.fromConfig(config, provider, { - disableRefresh: true, - providerId, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: true, + providerId, + tokenIssuer, + }); }); -} diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index d4455ac4d3..9ee2e1d680 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -22,26 +22,23 @@ import { executeRefreshTokenStrategy, makeProfileInfo, executeFetchUserProfileStrategy, -} from '../../lib/PassportStrategyHelper'; + PassportDoneCallback, +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; import { - OAuthProviderHandlers, - RedirectInfo, - AuthProviderConfig, + OAuthAdapter, + OAuthHandlers, OAuthProviderOptions, OAuthResponse, - PassportDoneCallback, -} from '../types'; -import { OAuthProvider } from '../../lib/OAuthProvider'; + OAuthEnvironmentHandler, +} from '../../lib/oauth'; import passport from 'passport'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity'; -import { Config } from '@backstage/config'; type PrivateInfo = { refreshToken: string; }; -export class GoogleAuthProvider implements OAuthProviderHandlers { +export class GoogleAuthProvider implements OAuthHandlers { private readonly _strategy: GoogleStrategy; constructor(options: OAuthProviderOptions) { @@ -148,27 +145,26 @@ export class GoogleAuthProvider implements OAuthProviderHandlers { } } -export function createGoogleProvider( - config: AuthProviderConfig, - _: string, - envConfig: Config, - _logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'google'; - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`; +export const createGoogleProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'google'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const provider = new GoogleAuthProvider({ - clientId, - clientSecret, - callbackUrl, - }); + const provider = new GoogleAuthProvider({ + clientId, + clientSecret, + callbackUrl, + }); - return OAuthProvider.fromConfig(config, provider, { - disableRefresh: false, - providerId, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); -} diff --git a/plugins/auth-backend/src/providers/microsoft/provider.ts b/plugins/auth-backend/src/providers/microsoft/provider.ts index 5997d8e1a8..edc5509d84 100644 --- a/plugins/auth-backend/src/providers/microsoft/provider.ts +++ b/plugins/auth-backend/src/providers/microsoft/provider.ts @@ -24,21 +24,18 @@ import { executeRefreshTokenStrategy, makeProfileInfo, executeFetchUserProfileStrategy, -} from '../../lib/PassportStrategyHelper'; + PassportDoneCallback, +} from '../../lib/passport'; + +import { RedirectInfo, AuthProviderFactory } from '../types'; import { - OAuthProviderHandlers, - RedirectInfo, - AuthProviderConfig, + OAuthAdapter, OAuthProviderOptions, + OAuthHandlers, OAuthResponse, - PassportDoneCallback, -} from '../types'; - -import { OAuthProvider } from '../../lib/OAuthProvider'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity'; -import { Config } from '@backstage/config'; + OAuthEnvironmentHandler, +} from '../../lib/oauth'; import got from 'got'; @@ -51,7 +48,7 @@ export type MicrosoftAuthProviderOptions = OAuthProviderOptions & { tokenUrl?: string; }; -export class MicrosoftAuthProvider implements OAuthProviderHandlers { +export class MicrosoftAuthProvider implements OAuthHandlers { private readonly _strategy: MicrosoftStrategy; static transformAuthResponse( @@ -205,34 +202,33 @@ export class MicrosoftAuthProvider implements OAuthProviderHandlers { } } -export function createMicrosoftProvider( - config: AuthProviderConfig, - _: string, - envConfig: Config, - _logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'microsoft'; +export const createMicrosoftProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'microsoft'; - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const tenantID = envConfig.getString('tenantId'); + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const tenantID = envConfig.getString('tenantId'); - const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`; - const authorizationUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`; - const tokenUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`; + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const authorizationUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`; + const tokenUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`; - const provider = new MicrosoftAuthProvider({ - clientId, - clientSecret, - callbackUrl, - authorizationUrl, - tokenUrl, + const provider = new MicrosoftAuthProvider({ + clientId, + clientSecret, + callbackUrl, + authorizationUrl, + tokenUrl, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); - - return OAuthProvider.fromConfig(config, provider, { - disableRefresh: false, - providerId, - tokenIssuer, - }); -} diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts index 4b8a826d81..5a4882fa6f 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -17,25 +17,22 @@ import express from 'express'; import passport from 'passport'; import { Strategy as OAuth2Strategy } from 'passport-oauth2'; -import { Logger } from 'winston'; -import { TokenIssuer } from '../../identity'; -import { OAuthProvider } from '../../lib/OAuthProvider'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, +} from '../../lib/oauth'; import { executeFetchUserProfileStrategy, executeFrameHandlerStrategy, executeRedirectStrategy, executeRefreshTokenStrategy, makeProfileInfo, -} from '../../lib/PassportStrategyHelper'; -import { - AuthProviderConfig, - OAuthProviderOptions, - OAuthProviderHandlers, - OAuthResponse, PassportDoneCallback, - RedirectInfo, -} from '../types'; -import { Config } from '@backstage/config'; +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; type PrivateInfo = { refreshToken: string; @@ -46,7 +43,7 @@ export type OAuth2AuthProviderOptions = OAuthProviderOptions & { tokenUrl: string; }; -export class OAuth2AuthProvider implements OAuthProviderHandlers { +export class OAuth2AuthProvider implements OAuthHandlers { private readonly _strategy: OAuth2Strategy; constructor(options: OAuth2AuthProviderOptions) { @@ -159,31 +156,30 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers { } } -export function createOAuth2Provider( - config: AuthProviderConfig, - _: string, - envConfig: Config, - _logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'oauth2'; - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`; - const authorizationUrl = envConfig.getString('authorizationUrl'); - const tokenUrl = envConfig.getString('tokenUrl'); +export const createOAuth2Provider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'oauth2'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const authorizationUrl = envConfig.getString('authorizationUrl'); + const tokenUrl = envConfig.getString('tokenUrl'); - const provider = new OAuth2AuthProvider({ - clientId, - clientSecret, - callbackUrl, - authorizationUrl, - tokenUrl, - }); + const provider = new OAuth2AuthProvider({ + clientId, + clientSecret, + callbackUrl, + authorizationUrl, + tokenUrl, + }); - return OAuthProvider.fromConfig(config, provider, { - disableRefresh: false, - providerId, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); -} diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index db8e0cf313..0368bd8415 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -14,7 +14,13 @@ * limitations under the License. */ import express from 'express'; -import { OAuthProvider } from '../../lib/OAuthProvider'; +import { + OAuthAdapter, + OAuthProviderOptions, + OAuthHandlers, + OAuthResponse, + OAuthEnvironmentHandler, +} from '../../lib/oauth'; import { Strategy as OktaStrategy } from 'passport-okta-oauth'; import passport from 'passport'; import { @@ -23,19 +29,10 @@ import { executeRefreshTokenStrategy, makeProfileInfo, executeFetchUserProfileStrategy, -} from '../../lib/PassportStrategyHelper'; -import { - OAuthProviderHandlers, - RedirectInfo, - AuthProviderConfig, - OAuthProviderOptions, - OAuthResponse, PassportDoneCallback, -} from '../types'; -import { Logger } from 'winston'; +} from '../../lib/passport'; +import { RedirectInfo, AuthProviderFactory } from '../types'; import { StateStore } from 'passport-oauth2'; -import { TokenIssuer } from '../../identity'; -import { Config } from '@backstage/config'; type PrivateInfo = { refreshToken: string; @@ -45,7 +42,7 @@ export type OktaAuthProviderOptions = OAuthProviderOptions & { audience: string; }; -export class OktaAuthProvider implements OAuthProviderHandlers { +export class OktaAuthProvider implements OAuthHandlers { private readonly _strategy: any; /** @@ -170,29 +167,28 @@ export class OktaAuthProvider implements OAuthProviderHandlers { } } -export function createOktaProvider( - config: AuthProviderConfig, - _: string, - envConfig: Config, - _logger: Logger, - tokenIssuer: TokenIssuer, -) { - const providerId = 'okta'; - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const audience = envConfig.getString('audience'); - const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`; +export const createOktaProvider: AuthProviderFactory = ({ + globalConfig, + config, + tokenIssuer, +}) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const providerId = 'okta'; + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getString('audience'); + const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const provider = new OktaAuthProvider({ - audience, - clientId, - clientSecret, - callbackUrl, - }); + const provider = new OktaAuthProvider({ + audience, + clientId, + clientSecret, + callbackUrl, + }); - return OAuthProvider.fromConfig(config, provider, { - disableRefresh: false, - providerId, - tokenIssuer, + return OAuthAdapter.fromConfig(globalConfig, provider, { + disableRefresh: false, + providerId, + tokenIssuer, + }); }); -} diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 56793d85f4..2bd8ed0bf3 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -23,17 +23,15 @@ import { import { executeFrameHandlerStrategy, executeRedirectStrategy, -} from '../../lib/PassportStrategyHelper'; -import { - AuthProviderConfig, - AuthProviderRouteHandlers, PassportDoneCallback, +} from '../../lib/passport'; +import { + AuthProviderRouteHandlers, ProfileInfo, + AuthProviderFactory, } from '../types'; -import { postMessageResponse } from '../../lib/OAuthProvider'; -import { Logger } from 'winston'; +import { postMessageResponse } from '../../lib/flow'; import { TokenIssuer } from '../../identity'; -import { Config } from '@backstage/config'; type SamlInfo = { userId: string; @@ -119,15 +117,12 @@ type SAMLProviderOptions = { tokenIssuer: TokenIssuer; }; -export function createSamlProvider( - _authProviderConfig: AuthProviderConfig, - _env: string, - envConfig: Config, - _logger: Logger, - tokenIssuer: TokenIssuer, -) { - const entryPoint = envConfig.getString('entryPoint'); - const issuer = envConfig.getString('issuer'); +export const createSamlProvider: AuthProviderFactory = ({ + config, + tokenIssuer, +}) => { + const entryPoint = config.getString('entryPoint'); + const issuer = config.getString('issuer'); const opts = { entryPoint, issuer, @@ -136,4 +131,4 @@ export function createSamlProvider( }; return new SamlAuthProvider(opts); -} +}; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index b3b7e518cc..5c05b02739 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -19,21 +19,6 @@ import { Logger } from 'winston'; import { TokenIssuer } from '../identity'; import { Config } from '@backstage/config'; -export type OAuthProviderOptions = { - /** - * Client ID of the auth provider. - */ - clientId: string; - /** - * Client Secret of the auth provider. - */ - clientSecret: string; - /** - * Callback URL to be passed to the auth provider to redirect to after the user signs in. - */ - callbackUrl: string; -}; - export type AuthProviderConfig = { /** * The protocol://domain[:port] where the app is hosted. This is used to construct the @@ -47,48 +32,16 @@ export type AuthProviderConfig = { appUrl: string; }; -/** - * Any OAuth provider needs to implement this interface which has provider specific - * handlers for different methods to perform authentication, get access tokens, - * refresh tokens and perform sign out. - */ -export interface OAuthProviderHandlers { +export type RedirectInfo = { /** - * This method initiates a sign in request with an auth provider. - * @param {express.Request} req - * @param options + * URL to redirect to */ - start( - req: express.Request, - options: Record, - ): Promise; - + url: string; /** - * Handles the redirect from the auth provider when the user has signed in. - * @param {express.Request} req + * Status code to use for the redirect */ - handler( - req: express.Request, - ): Promise<{ - response: AuthResponse; - refreshToken?: string; - }>; - - /** - * (Optional) Given a refresh token and scope fetches a new access token from the auth provider. - * @param {string} refreshToken - * @param {string} scope - */ - refresh?( - refreshToken: string, - scope: string, - ): Promise>; - - /** - * (Optional) Sign out of the auth provider. - */ - logout?(): Promise; -} + status?: number; +}; /** * Any Auth provider needs to implement this interface which handles the routes in the @@ -155,24 +108,18 @@ export interface AuthProviderRouteHandlers { * @param {express.Response} res */ logout?(req: express.Request, res: express.Response): Promise; - - /** - *(Optional) A method to identify the environment Context of the Request - * - *Request - *- contains the environment context information encoded in the request - * @param {express.Request} req - */ - identifyEnv?(req: express.Request): string | undefined; } +export type AuthProviderFactoryOptions = { + globalConfig: AuthProviderConfig; + config: Config; + logger: Logger; + tokenIssuer: TokenIssuer; +}; + export type AuthProviderFactory = ( - globalConfig: AuthProviderConfig, - env: string, - envConfig: Config, - logger: Logger, - issuer: TokenIssuer, -) => AuthProviderRouteHandlers | undefined; + options: AuthProviderFactoryOptions, +) => AuthProviderRouteHandlers; export type AuthResponse = { providerInfo: ProviderInfo; @@ -180,8 +127,6 @@ export type AuthResponse = { backstageIdentity?: BackstageIdentity; }; -export type OAuthResponse = AuthResponse; - export type BackstageIdentity = { /** * The backstage user ID. @@ -194,67 +139,6 @@ export type BackstageIdentity = { idToken?: string; }; -export type OAuthProviderInfo = { - /** - * An access token issued for the signed in user. - */ - accessToken: string; - /** - * (Optional) Id token issued for the signed in user. - */ - idToken?: string; - /** - * Expiry of the access token in seconds. - */ - expiresInSeconds?: number; - /** - * Scopes granted for the access token. - */ - scope: string; - /** - * A refresh token issued for the signed in user - */ - refreshToken?: string; -}; - -export type OAuthPrivateInfo = { - /** - * A refresh token issued for the signed in user. - */ - refreshToken: string; -}; - -/** - * Payload sent as a post message after the auth request is complete. - * If successful then has a valid payload with Auth information else contains an error. - */ -export type WebMessageResponse = - | { - type: 'authorization_response'; - response: AuthResponse; - } - | { - type: 'authorization_response'; - error: Error; - }; - -export type PassportDoneCallback = ( - err?: Error, - response?: Res, - privateInfo?: Private, -) => void; - -export type RedirectInfo = { - /** - * URL to redirect to - */ - url: string; - /** - * Status code to use for the redirect - */ - status?: number; -}; - /** * Used to display login information to user, i.e. sidebar popup. * @@ -276,39 +160,3 @@ export type ProfileInfo = { */ picture?: string; }; - -export type RefreshTokenResponse = { - /** - * An access token issued for the signed in user. - */ - accessToken: string; - /** - * Optionally, the server can issue a new Refresh Token for the user - */ - refreshToken?: string; - params: any; -}; - -export type ProviderStrategy = { - userProfile(accessToken: string, callback: Function): void; -}; - -export type SAMLProviderConfig = { - entryPoint: string; - issuer: string; -}; - -export type SAMLEnvironmentProviderConfig = { - [key: string]: SAMLProviderConfig; -}; - -export type OAuthState = { - /* A type for the serialized value in the `state` parameter of the OAuth authorization flow - */ - nonce: string; - env: string; -}; - -export type EnvironmentIdentifierFn = ( - req: express.Request, -) => string | undefined; diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index b5451cad6c..19a74d47c5 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -22,6 +22,7 @@ import { Logger } from 'winston'; import { createAuthProviderRouter } from '../providers'; import { Config } from '@backstage/config'; import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity'; +import { NotFoundError } from '@backstage/backend-common'; export interface RouterOptions { logger: Logger; @@ -88,5 +89,10 @@ export async function createRouter( }), ); + router.use('/:provider/', req => { + const { provider } = req.params; + throw new NotFoundError(`No auth provider registered for '${provider}'`); + }); + return router; } diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.test.ts index 62259f87a2..9b234e3e75 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.test.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.test.ts @@ -41,7 +41,15 @@ describe('AzureApiReaderProcessor', () => { target: 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', url: new URL( - 'https://dev.azure.com/org-name/project-name/_apis/sourceProviders/TfsGit/filecontents?repository=repo-name&commitOrBranch=master&path=my-template.yaml&api-version=6.0-preview.1', + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', + ), + err: undefined, + }, + { + target: + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml', + url: new URL( + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', ), err: undefined, }, diff --git a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts index 7033521da6..03ff30ea69 100644 --- a/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/AzureApiReaderProcessor.ts @@ -83,7 +83,7 @@ export class AzureApiReaderProcessor implements LocationProcessor { // Converts // from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents - // to: https://dev.azure.com/{organization}/{project}/_apis/sourceProviders/{providerName}/filecontents?repository={repository}&commitOrBranch={commitOrBranch}&path={path}&api-version=6.0-preview.1 + // to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch} buildRawUrl(target: string): URL { try { const url = new URL(target); @@ -119,17 +119,19 @@ export class AzureApiReaderProcessor implements LocationProcessor { userOrOrg, project, '_apis', - 'sourceProviders', - 'TfsGit', - 'filecontents', + 'git', + 'repositories', + repoName, + 'items', ].join('/'); - url.search = [ - `repository=${repoName}`, - `commitOrBranch=${ref}`, - `path=${path}`, - 'api-version=6.0-preview.1', - ].join('&'); + const queryParams = [`path=${path}`]; + + if (ref) { + queryParams.push(`version=${ref}`); + } + + url.search = queryParams.join('&'); url.protocol = 'https'; diff --git a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts index b83c9a16f3..9f38d782fe 100644 --- a/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts +++ b/plugins/catalog-backend/src/ingestion/processors/GithubReaderProcessor.ts @@ -15,11 +15,35 @@ */ import { LocationSpec } from '@backstage/catalog-model'; -import fetch from 'node-fetch'; +import fetch, { RequestInit, HeadersInit } from 'node-fetch'; import * as result from './results'; import { LocationProcessor, LocationProcessorEmit } from './types'; +import { Config } from '@backstage/config'; export class GithubReaderProcessor implements LocationProcessor { + private privateToken: string; + + constructor(config?: Config) { + this.privateToken = + config?.getOptionalString('catalog.processors.github.privateToken') ?? ''; + } + + getRequestOptions(): RequestInit { + const headers: HeadersInit = { + Accept: 'application/vnd.github.v3.raw', + }; + + if (this.privateToken !== '') { + headers.Authorization = `token ${this.privateToken}`; + } + + const requestOptions: RequestInit = { + headers, + }; + + return requestOptions; + } + async readLocation( location: LocationSpec, optional: boolean, @@ -34,7 +58,7 @@ export class GithubReaderProcessor implements LocationProcessor { // TODO(freben): Should "hard" errors thrown by this line be treated as // notFound instead of fatal? - const response = await fetch(url.toString()); + const response = await fetch(url.toString(), this.getRequestOptions()); if (response.ok) { const data = await response.buffer(); diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index b088e1699b..e393be9d17 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -27,6 +27,7 @@ "@backstage/plugin-github-actions": "^0.1.1-alpha.21", "@backstage/plugin-jenkins": "^0.1.1-alpha.21", "@backstage/plugin-scaffolder": "^0.1.1-alpha.21", + "@backstage/plugin-sentry": "^0.1.1-alpha.21", "@backstage/plugin-techdocs": "^0.1.1-alpha.21", "@backstage/theme": "^0.1.1-alpha.21", "@material-ui/core": "^4.9.1", diff --git a/plugins/catalog/src/components/EntityPageCi/EntityPageCi.tsx b/plugins/catalog/src/components/EntityPageCi/EntityPageCi.tsx index 3261764d11..0974e6bb83 100644 --- a/plugins/catalog/src/components/EntityPageCi/EntityPageCi.tsx +++ b/plugins/catalog/src/components/EntityPageCi/EntityPageCi.tsx @@ -17,7 +17,10 @@ // TODO(shmidt-i): move to the app import { Entity } from '@backstage/catalog-model'; import { Content } from '@backstage/core'; -import { LatestWorkflowsForBranchCard } from '@backstage/plugin-github-actions'; +import { + LatestWorkflowsForBranchCard, + GITHUB_ACTIONS_ANNOTATION, +} from '@backstage/plugin-github-actions'; import { Grid } from '@material-ui/core'; import React, { FC } from 'react'; @@ -25,7 +28,7 @@ export const EntityPageCi: FC<{ entity: Entity }> = ({ entity }) => { return ( - {entity.metadata?.annotations?.['backstage.io/github-actions-id'] && ( + {entity.metadata?.annotations?.[GITHUB_ACTIONS_ANNOTATION] && ( diff --git a/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx b/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx index 9b29b205cf..c43e83edaa 100644 --- a/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx +++ b/plugins/catalog/src/components/EntityPageOverview/EntityPageOverview.tsx @@ -17,7 +17,10 @@ // TODO(shmidt-i): move to the app import { Entity } from '@backstage/catalog-model'; import { Content } from '@backstage/core'; -import { LatestWorkflowRunCard } from '@backstage/plugin-github-actions'; +import { + LatestWorkflowRunCard, + GITHUB_ACTIONS_ANNOTATION, +} from '@backstage/plugin-github-actions'; import { JenkinsBuildsWidget, JenkinsLastBuildWidget, @@ -47,7 +50,7 @@ export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => { )} - {entity.metadata?.annotations?.['backstage.io/github-actions-id'] && ( + {entity.metadata?.annotations?.[GITHUB_ACTIONS_ANNOTATION] && ( diff --git a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.tsx b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.tsx index eb500da651..8c5737b7b6 100644 --- a/plugins/catalog/src/components/ResultsFilter/ResultsFilter.tsx +++ b/plugins/catalog/src/components/ResultsFilter/ResultsFilter.tsx @@ -104,6 +104,7 @@ export const ResultsFilter = ({ availableTags }: Props) => { > ({ + id: 'plugin.gcpprojects.service', + description: 'Used by the GCP Projects plugin to make requests', +}); + +export type GCPApi = { + listProjects: ({ token }: { token: string }) => Promise; + getProject: (projectId: string, token: Promise) => Promise; + createProject: ( + projectName: string, + projectId: string, + owner: string, + token: string, + ) => Promise; +}; diff --git a/plugins/gcp-projects/src/api/GCPClient.ts b/plugins/gcp-projects/src/api/GCPClient.ts new file mode 100644 index 0000000000..dc4fe21132 --- /dev/null +++ b/plugins/gcp-projects/src/api/GCPClient.ts @@ -0,0 +1,124 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GCPApi } from './GCPApi'; +import { Project, Operation, Status } from './types'; + +const BaseURL = + 'https://content-cloudresourcemanager.googleapis.com/v1/projects'; + +export class GCPClient implements GCPApi { + async listProjects({ token }: { token: string }): Promise { + const response = await fetch(BaseURL, { + headers: new Headers({ + Accept: '*/*', + Authorization: `Bearer ${token}`, + }), + }); + + if (!response.ok) { + return [ + { + name: 'Error', + projectNumber: 'Response status is not OK', + projectId: 'Error', + lifecycleState: 'error', + createTime: 'Error', + }, + ]; + } + + const data = await response.json(); + + return data.projects; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getProject( + projectId: string, + token: Promise, + ): Promise { + const url = `${BaseURL}/${projectId}`; + const response = await fetch(url, { + headers: new Headers({ + Authorization: `Bearer ${await token}`, + }), + }); + + const dataBlank: Project = { + name: 'Error', + projectNumber: `Response status is ${response.status}`, + projectId: 'Error', + lifecycleState: 'error', + createTime: 'Error', + }; + + if (!response.ok) { + return dataBlank; + } + + const data = await response.json(); + + const newData: Project = data; + + return newData; + } + + async createProject( + projectName: string, + projectId: string, + token: string, + ): Promise { + const status: Status = { + code: 0, + message: '', + details: [], + }; + + const op: Operation = { + name: '', + metadata: '', + done: true, + error: status, + response: '', + }; + + const newProject: Project = { + name: projectName, + projectId: projectId, + }; + + const body = JSON.stringify(newProject); + + const response = await fetch(BaseURL, { + headers: new Headers({ + Accept: '*/*', + Authorization: `Bearer ${token}`, + }), + body: body, + method: 'POST', + }); + + if (!response.ok) { + status.code = response.status; + return op; + } + + const data = await response.json(); + + return data; + } +} diff --git a/plugins/gcp-projects/src/api/index.ts b/plugins/gcp-projects/src/api/index.ts new file mode 100644 index 0000000000..51f617f19c --- /dev/null +++ b/plugins/gcp-projects/src/api/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './GCPApi'; +export * from './GCPClient'; +export * from './types'; diff --git a/plugins/gcp-projects/src/api/types.ts b/plugins/gcp-projects/src/api/types.ts new file mode 100644 index 0000000000..8dccaec5e2 --- /dev/null +++ b/plugins/gcp-projects/src/api/types.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type Project = { + name: string; + projectNumber?: string; + projectId: string; + lifecycleState?: string; + createTime?: string; +}; + +export type ProjectDetails = { + details: string; +}; + +export type Operation = { + name: string; + metadata: string; + done: boolean; + error: Status; + response: string; +}; + +export type Status = { + code: number; + message: string; + details: string[]; +}; diff --git a/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx b/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx new file mode 100644 index 0000000000..0149f8ac92 --- /dev/null +++ b/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx @@ -0,0 +1,131 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useState } from 'react'; +import { Grid, Button, TextField } from '@material-ui/core'; + +import { + InfoCard, + Content, + ContentHeader, + SimpleStepper, + SimpleStepperStep, + StructuredMetadataTable, + HeaderLabel, + Page, + Header, + pageTheme, + SupportButton, +} from '@backstage/core'; + +export const Project: FC<{}> = () => { + const [projectName, setProjectName] = useState(''); + const [projectId, setProjectId] = useState(''); + const [disabled, setDisabled] = useState(true); + + const metadata = { + ProjectName: projectName, + ProjectId: projectId, + }; + + return ( + + + + + + + setProjectName(e.target.value)} + value={projectName} + fullWidth + /> + + + setProjectId(e.target.value)} + value={projectId} + fullWidth + /> + + + setDisabled(false), + }} + > + + + + + + + + + + ); +}; + +const labels = ( + <> + + + +); + +export const NewProjectPage = () => { + return ( + +
+ {labels} +
+ + + + This plugin allows you to view and interact with your gcp projects. + + + + +
+ ); +}; diff --git a/plugins/gcp-projects/src/components/NewProjectPage/index.ts b/plugins/gcp-projects/src/components/NewProjectPage/index.ts new file mode 100644 index 0000000000..1d2f023887 --- /dev/null +++ b/plugins/gcp-projects/src/components/NewProjectPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { NewProjectPage } from './NewProjectPage'; diff --git a/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx b/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx new file mode 100644 index 0000000000..41d0b11019 --- /dev/null +++ b/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx @@ -0,0 +1,171 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Button, + ButtonGroup, + LinearProgress, + makeStyles, + Paper, + Table, + TableBody, + TableCell, + TableRow, + Theme, + Typography, +} from '@material-ui/core'; +import { + useApi, + googleAuthApiRef, + HeaderLabel, + Page, + Header, + pageTheme, + SupportButton, + Content, + ContentHeader, +} from '@backstage/core'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { GCPApiRef } from '../../api'; + +const useStyles = makeStyles(theme => ({ + root: { + maxWidth: 720, + margin: theme.spacing(2), + }, + title: { + padding: theme.spacing(1, 0, 2, 0), + }, + table: { + padding: theme.spacing(1), + }, +})); + +const DetailsPage = () => { + const api = useApi(GCPApiRef); + const googleApi = useApi(googleAuthApiRef); + const token = googleApi.getAccessToken( + 'https://www.googleapis.com/auth/cloud-platform.read-only', + ); + + const classes = useStyles(); + const status = useAsync( + () => + api.getProject( + decodeURIComponent(location.search.split('projectId=')[1]), + token, + ), + [location.search], + ); + + if (status.loading) { + return ; + } else if (status.error) { + return ( + + Failed to load build, {status.error.message} + + ); + } + + const details = status.value; + + return ( +
+
+ + + + Name + + {details?.name} + + + + Project Number + + {details?.projectNumber} + + + + Project ID + + {details?.projectId} + + + + State + + {details?.lifecycleState} + + + + Creation Time + + {details?.createTime} + + + + Links + + + + {details?.name && ( + + )} + {details?.name && ( + + )} + + + + +
+ + ); +}; + +const labels = ( + <> + + + +); + +export const ProjectDetailsPage = () => { + return ( + +
+ {labels} +
+ + + Support Button + + + +
+ ); +}; diff --git a/plugins/gcp-projects/src/components/ProjectDetailsPage/index.ts b/plugins/gcp-projects/src/components/ProjectDetailsPage/index.ts new file mode 100644 index 0000000000..e9b6eb095a --- /dev/null +++ b/plugins/gcp-projects/src/components/ProjectDetailsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ProjectDetailsPage } from './ProjectDetailsPage'; diff --git a/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx new file mode 100644 index 0000000000..7e09a307c9 --- /dev/null +++ b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx @@ -0,0 +1,164 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// NEEDS WORK + +import { + Link, + useApi, + googleAuthApiRef, + HeaderLabel, + Page, + Header, + pageTheme, + SupportButton, + Content, + ContentHeader, +} from '@backstage/core'; +import { + LinearProgress, + Paper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, + Button, +} from '@material-ui/core'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { GCPApiRef, Project } from '../../api'; + +const LongText = ({ text, max }: { text: string; max: number }) => { + if (text.length < max) { + return {text}; + } + return ( + + {text.slice(0, max)}... + + ); +}; + +const labels = ( + <> + + + +); + +const PageContents = () => { + const api = useApi(GCPApiRef); + const googleApi = useApi(googleAuthApiRef); + + const { loading, error, value } = useAsync(async () => { + const token = await googleApi.getAccessToken( + 'https://www.googleapis.com/auth/cloud-platform.read-only', + ); + + const projects = api.listProjects({ token }); + return projects; + }); + + if (loading) { + return ; + } + + if (error) { + return ( + + {error.message}{' '} + + ); + } + + return ( + +
+ + + Name + Project Number + Project ID + State + Creation Time + + + + {value?.map((project: Project) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ))} + +
+ + ); +}; + +export const ProjectListPage = () => { + return ( + +
+ {labels} +
+ + + + All your software catalog entities + + + +
+ ); +}; diff --git a/plugins/gcp-projects/src/components/ProjectListPage/index.ts b/plugins/gcp-projects/src/components/ProjectListPage/index.ts new file mode 100644 index 0000000000..c2b0479cef --- /dev/null +++ b/plugins/gcp-projects/src/components/ProjectListPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ProjectListPage } from './ProjectListPage'; diff --git a/plugins/gcp-projects/src/index.ts b/plugins/gcp-projects/src/index.ts new file mode 100644 index 0000000000..d67bc6a864 --- /dev/null +++ b/plugins/gcp-projects/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { plugin } from './plugin'; +export * from './api'; diff --git a/plugins/gcp-projects/src/plugin.test.ts b/plugins/gcp-projects/src/plugin.test.ts new file mode 100644 index 0000000000..86e909995f --- /dev/null +++ b/plugins/gcp-projects/src/plugin.test.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { plugin } from './plugin'; + +describe('gcp-projects', () => { + it('should export plugin', () => { + expect(plugin).toBeDefined(); + }); +}); diff --git a/plugins/gcp-projects/src/plugin.ts b/plugins/gcp-projects/src/plugin.ts new file mode 100644 index 0000000000..29c7d74434 --- /dev/null +++ b/plugins/gcp-projects/src/plugin.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createPlugin, createRouteRef } from '@backstage/core'; +import { ProjectListPage } from './components/ProjectListPage'; +import { ProjectDetailsPage } from './components/ProjectDetailsPage'; +import { NewProjectPage } from './components/NewProjectPage'; + +export const rootRouteRef = createRouteRef({ + path: '/gcp-projects', + title: 'GCP Projects', +}); +export const ProjectRouteRef = createRouteRef({ + path: '/gcp-projects/project', + title: 'GCP Project Page', +}); +export const NewProjectRouteRef = createRouteRef({ + path: '/gcp-projects/new', + title: 'GCP Project Page', +}); + +export const plugin = createPlugin({ + id: 'gcp-projects', + register({ router }) { + router.addRoute(rootRouteRef, ProjectListPage); + router.addRoute(ProjectRouteRef, ProjectDetailsPage); + router.addRoute(NewProjectRouteRef, NewProjectPage); + }, +}); diff --git a/plugins/gcp-projects/src/setupTests.ts b/plugins/gcp-projects/src/setupTests.ts new file mode 100644 index 0000000000..8553642152 --- /dev/null +++ b/plugins/gcp-projects/src/setupTests.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; + +require('jest-fetch-mock').enableMocks(); diff --git a/plugins/github-actions/src/components/Cards/Cards.tsx b/plugins/github-actions/src/components/Cards/Cards.tsx index 478b3810cb..28783efdea 100644 --- a/plugins/github-actions/src/components/Cards/Cards.tsx +++ b/plugins/github-actions/src/components/Cards/Cards.tsx @@ -32,6 +32,7 @@ import { useApi, } from '@backstage/core'; import ExternalLinkIcon from '@material-ui/icons/Launch'; +import { GITHUB_ACTIONS_ANNOTATION } from '../useProjectName'; const useStyles = makeStyles({ externalLinkIcon: { @@ -83,7 +84,7 @@ export const LatestWorkflowRunCard = ({ }) => { const errorApi = useApi(errorApiRef); const [owner, repo] = ( - entity?.metadata.annotations?.['backstage.io/github-actions-id'] ?? '/' + entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '/' ).split('/'); const [{ runs, loading, error }] = useWorkflowRuns({ owner, diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index ef429cf94b..720eb7cc15 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -48,7 +48,7 @@ "@octokit/types": "^5.0.1", "@types/fs-extra": "^9.0.1", "@types/git-url-parse": "^9.0.0", - "@types/nodegit": "0.26.7", + "@types/nodegit": "0.26.8", "@types/supertest": "^2.0.8", "supertest": "^4.0.2", "yaml": "^1.10.0" diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml index 832b539ac6..f6e1d2c8d9 100644 --- a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.component_id}}/component-info.yaml @@ -5,7 +5,6 @@ metadata: description: {{cookiecutter.description}} annotations: github.com/project-slug: {{cookiecutter.storePath}} - backstage.io/github-actions-id: {{cookiecutter.storePath}} backstage.io/techdocs-ref: github:https://github.com/{{cookiecutter.storePath}} spec: type: website diff --git a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml index 15c8fdcfff..618209f44c 100644 --- a/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml +++ b/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/{{cookiecutter.component_id}}/component-info.yaml @@ -5,7 +5,6 @@ metadata: description: {{cookiecutter.description}} annotations: github.com/project-slug: {{cookiecutter.storePath}} - backstage.io/github-actions-id: {{cookiecutter.storePath}} spec: type: service lifecycle: experimental diff --git a/plugins/sentry-backend/src/service/sentry-api.ts b/plugins/sentry-backend/src/service/sentry-api.ts index 5c8a9f1aa5..8d35038840 100644 --- a/plugins/sentry-backend/src/service/sentry-api.ts +++ b/plugins/sentry-backend/src/service/sentry-api.ts @@ -26,7 +26,7 @@ export function getRequestHeaders(token: string) { } export function getSentryApiForwarder(token: string, logger: Logger) { - return function fowardRequest( + return function forwardRequest( request: express.Request, response: express.Response, ) { diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index d5b29cc307..294d7159bf 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -21,6 +21,7 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/catalog-model": "^0.1.1-alpha.21", "@backstage/core": "^0.1.1-alpha.21", "@backstage/theme": "^0.1.1-alpha.21", "@material-ui/core": "^4.9.1", @@ -29,6 +30,7 @@ "@types/react": "^16.9", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", "react-sparklines": "^1.7.0", "react-use": "^15.3.3", "timeago.js": "^4.0.2" diff --git a/plugins/sentry/src/components/Router.tsx b/plugins/sentry/src/components/Router.tsx new file mode 100644 index 0000000000..47d532b4e3 --- /dev/null +++ b/plugins/sentry/src/components/Router.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { Routes, Route } from 'react-router'; +import { WarningPanel } from '@backstage/core'; +import { SentryPluginWidget } from './SentryPluginWidget/SentryPluginWidget'; + +const SENTRY_ANNOTATION = 'sentry.io/project-slug'; + +export const Router = ({ entity }: { entity: Entity }) => { + const projectId = entity.metadata.annotations?.[SENTRY_ANNOTATION]; + + if (!projectId) { + return ( + +
{SENTRY_ANNOTATION}
annotation is missing on the entity. +
+ ); + } + + return ( + + + } + /> + ) + + ); +}; diff --git a/plugins/sentry/src/index.ts b/plugins/sentry/src/index.ts index 50da801a40..a0d3cab1be 100644 --- a/plugins/sentry/src/index.ts +++ b/plugins/sentry/src/index.ts @@ -15,4 +15,5 @@ */ export { plugin } from './plugin'; +export { Router } from './components/Router'; export { SentryPluginWidget as SentryIssuesWidget } from './components/SentryPluginWidget/SentryPluginWidget'; diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts index 0d29d9c7be..99a7d3bb5c 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/github.test.ts @@ -16,11 +16,13 @@ import { getVoidLogger } from '@backstage/backend-common'; import { GithubPreparer } from './github'; -import { checkoutGitRepository } from './helpers'; +import { checkoutGithubRepository } from './helpers'; jest.mock('./helpers', () => ({ ...jest.requireActual<{}>('./helpers'), - checkoutGitRepository: jest.fn(() => '/tmp/backstage-repo/org/name/branch'), + checkoutGithubRepository: jest.fn( + () => '/tmp/backstage-repo/org/name/branch', + ), })); const createMockEntity = (annotations = {}) => { @@ -48,7 +50,7 @@ describe('github preparer', () => { }); const tempDocsPath = await preparer.prepare(mockEntity); - expect(checkoutGitRepository).toHaveBeenCalledTimes(1); + expect(checkoutGithubRepository).toHaveBeenCalledTimes(1); expect(tempDocsPath).toEqual( '/tmp/backstage-repo/org/name/branch/plugins/techdocs-backend/examples/documented-component', ); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts index 65c3e551cb..242b19c24d 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/github.ts @@ -18,7 +18,7 @@ import { Entity } from '@backstage/catalog-model'; import { InputError } from '@backstage/backend-common'; import { PreparerBase } from './types'; import parseGitUrl from 'git-url-parse'; -import { parseReferenceAnnotation, checkoutGitRepository } from './helpers'; +import { parseReferenceAnnotation, checkoutGithubRepository } from './helpers'; import { Logger } from 'winston'; export class GithubPreparer implements PreparerBase { @@ -39,7 +39,7 @@ export class GithubPreparer implements PreparerBase { } try { - const repoPath = await checkoutGitRepository(target); + const repoPath = await checkoutGithubRepository(target); const parsedGitLocation = parseGitUrl(target); return path.join(repoPath, parsedGitLocation.filepath); diff --git a/plugins/techdocs-backend/src/techdocs/stages/prepare/helpers.ts b/plugins/techdocs-backend/src/techdocs/stages/prepare/helpers.ts index 27e746590a..d962242262 100644 --- a/plugins/techdocs-backend/src/techdocs/stages/prepare/helpers.ts +++ b/plugins/techdocs-backend/src/techdocs/stages/prepare/helpers.ts @@ -94,3 +94,45 @@ export const checkoutGitRepository = async ( return repositoryTmpPath; }; + +// Could be merged with checkoutGitRepository +export const checkoutGithubRepository = async ( + repoUrl: string, +): Promise => { + const parsedGitLocation = parseGitUrl(repoUrl); + + // Should propably not be hardcoded names of env variables, but seems too hard to access config down here + const user = process.env.GITHUB_PRIVATE_TOKEN_USER || ''; + const token = process.env.GITHUB_PRIVATE_TOKEN || ''; + + const repositoryTmpPath = path.join( + // fs.realpathSync fixes a problem with macOS returning a path that is a symlink + fs.realpathSync(os.tmpdir()), + 'backstage-repo', + parsedGitLocation.source, + parsedGitLocation.owner, + parsedGitLocation.name, + parsedGitLocation.ref, + ); + + if (fs.existsSync(repositoryTmpPath)) { + const repository = await Repository.open(repositoryTmpPath); + const currentBranchName = (await repository.getCurrentBranch()).shorthand(); + await repository.mergeBranches( + currentBranchName, + `origin/${currentBranchName}`, + ); + return repositoryTmpPath; + } + + if (user && token) { + parsedGitLocation.token = `${user}:${token}`; + } + + const repositoryCheckoutUrl = parsedGitLocation.toString('https'); + + fs.mkdirSync(repositoryTmpPath, { recursive: true }); + await Clone.clone(repositoryCheckoutUrl, repositoryTmpPath); + + return repositoryTmpPath; +}; diff --git a/yarn.lock b/yarn.lock index 23ef9b84f0..5b0bec8005 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2946,10 +2946,10 @@ resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-2.1.1.tgz#ceff6a28a5b4867c2dd4a1ba513de278ccbe8bb1" integrity sha512-/aPsuoj/1Dw/kzhkgz+ES6TxG0zfTMGLwuK2ZG00k/iJzYHTLCE8mVU8EPqEOp/lmxPoq1C1C9RYToRKb2KEfg== -"@sindresorhus/is@^3.0.0": - version "3.1.1" - resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-3.1.1.tgz#6e39e4222add8b362da35720c9dd5d53345d5851" - integrity sha512-tLnujxFtfH7F+i5ghUfgGlJsvyCKvUnSMFMlWybFdX9/DdX8svb4Zwx1gV0gkkVCHXtmPSetoAR3QlKfOld6Tw== +"@sindresorhus/is@^3.1.1": + version "3.1.2" + resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-3.1.2.tgz#548650de521b344e3781fbdb0ece4aa6f729afb8" + integrity sha512-JiX9vxoKMmu8Y3Zr2RVathBL1Cdu4Nt4MuNWemt1Nc06A0RAin9c5FArkhGsyMBWfCu4zj+9b+GxtjAnE4qqLQ== "@sinonjs/commons@^1.7.0": version "1.7.1" @@ -4526,10 +4526,10 @@ resolved "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz#fe1cc3aa465a3ea6858b793fd380b66c39919766" integrity sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw== -"@types/nodegit@0.26.7": - version "0.26.7" - resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.7.tgz#3fc8f5108dcabddd6238509748c0e812ba02b9b7" - integrity sha512-qVwQupq4To/wkRtUnaiwbNhuRiVJShOG9CYIWiGdKQRkvWijtHmEnrC1KJHOVrzK04sMdHRClyecrfmN17VMFQ== +"@types/nodegit@0.26.8": + version "0.26.8" + resolved "https://registry.npmjs.org/@types/nodegit/-/nodegit-0.26.8.tgz#81a79307fbdd1cf028a8ec7cbc94020bac54be6f" + integrity sha512-CwcynPazar6cJxjn8PG2RIujNgFvH2pAoA/bBBvPTUrONVzc9hj0DRMYzioy7zI/+e8TfJJLP+DK26mEU0eErg== dependencies: "@types/node" "*" @@ -5605,10 +5605,10 @@ apollo-server-caching@^0.5.2: dependencies: lru-cache "^5.0.0" -apollo-server-core@^2.16.1: - version "2.16.1" - resolved "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.16.1.tgz#5b5b8245ab9c0cb6c2367ec19ab855dea6ccea3a" - integrity sha512-nuwn5ZBbmzPwDetb3FgiFFJlNK7ZBFg8kis/raymrjd3eBGdNcOyMTJDl6J9673X9Xqp+dXQmFYDW/G3G8S1YA== +apollo-server-core@^2.17.0: + version "2.17.0" + resolved "https://registry.npmjs.org/apollo-server-core/-/apollo-server-core-2.17.0.tgz#6af697ffe4968e74add01cd1efd2a8fb33299cf3" + integrity sha512-rjAkBbKSrGLDfg/g5bohnPlQahmkAxgEBuMDVsoF3aa+RaEPXPUMYrLbOxntl0LWeLbPiMa/IyFF43dvlGqV7w== dependencies: "@apollographql/apollo-tools" "^0.4.3" "@apollographql/graphql-playground-html" "1.6.26" @@ -5622,7 +5622,7 @@ apollo-server-core@^2.16.1: apollo-server-errors "^2.4.2" apollo-server-plugin-base "^0.9.1" apollo-server-types "^0.5.1" - apollo-tracing "^0.11.1" + apollo-tracing "^0.11.2" fast-json-stable-stringify "^2.0.0" graphql-extensions "^0.12.4" graphql-tag "^2.9.2" @@ -5646,10 +5646,10 @@ apollo-server-errors@^2.4.2: resolved "https://registry.npmjs.org/apollo-server-errors/-/apollo-server-errors-2.4.2.tgz#1128738a1d14da989f58420896d70524784eabe5" integrity sha512-FeGxW3Batn6sUtX3OVVUm7o56EgjxDlmgpTLNyWcLb0j6P8mw9oLNyAm3B+deHA4KNdNHO5BmHS2g1SJYjqPCQ== -apollo-server-express@^2.16.0, apollo-server-express@^2.16.1: - version "2.16.1" - resolved "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.16.1.tgz#7438bca590ef8577d24d20ba0b6d582c120c0146" - integrity sha512-Oq5YNcaMYnRk6jDmA9LWf8oSd2KHDVe7jQ4wtooAvG9FVUD+FaFBgSkytXHMvtifQh2wdF07Ri8uDLMz6IQjTw== +apollo-server-express@^2.16.0, apollo-server-express@^2.17.0: + version "2.17.0" + resolved "https://registry.npmjs.org/apollo-server-express/-/apollo-server-express-2.17.0.tgz#2014559b75a0bcf7ff8cf0f2d077da6653abbc18" + integrity sha512-PonpWOuM1DH3Cz0bu56Tusr3GXOnectC6AD/gy2GXK0v84E7tKTuxEY3SgsgxhvfvvhfwJbXTyIogL/wezqnCw== dependencies: "@apollographql/graphql-playground-html" "1.6.26" "@types/accepts" "^1.3.5" @@ -5657,7 +5657,7 @@ apollo-server-express@^2.16.0, apollo-server-express@^2.16.1: "@types/cors" "^2.8.4" "@types/express" "4.17.7" accepts "^1.3.5" - apollo-server-core "^2.16.1" + apollo-server-core "^2.17.0" apollo-server-types "^0.5.1" body-parser "^1.18.3" cors "^2.8.4" @@ -5685,20 +5685,20 @@ apollo-server-types@^0.5.1: apollo-server-env "^2.4.5" apollo-server@^2.16.0: - version "2.16.1" - resolved "https://registry.npmjs.org/apollo-server/-/apollo-server-2.16.1.tgz#edc319606eb29f73132239bdc005dd88ac40a142" - integrity sha512-oy9NVRzGwlpQ+W1DwLKRH+KASmodSYpvYIRY5DMAZtGqNmT2zOCpbIZVjBt23SuPB5NhIhhE4ROzoObRv3zy5w== + version "2.17.0" + resolved "https://registry.npmjs.org/apollo-server/-/apollo-server-2.17.0.tgz#d015fe6d8d6bdd468bc43ecabcb29ceb41e0b286" + integrity sha512-vVMu+VqjmsB6yk5iNTb/AXM6EJGd2uwzrcTAbwqvGI7GqCYDRZlGBwrQCjOU/jT/EPWdNRWks/qhJYiQMeVXSg== dependencies: - apollo-server-core "^2.16.1" - apollo-server-express "^2.16.1" + apollo-server-core "^2.17.0" + apollo-server-express "^2.17.0" express "^4.0.0" graphql-subscriptions "^1.0.0" graphql-tools "^4.0.0" -apollo-tracing@^0.11.1: - version "0.11.1" - resolved "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.11.1.tgz#3e3a4ce4b21e57dcc57b10bbd539db243b752606" - integrity sha512-l7g+uILw7v32GA46IRXIx5XXbZhFI96BhSqrGK9yyvfq+NMcvVZrj3kIhRImPGhAjMdV+5biA/jztabElAbDjg== +apollo-tracing@^0.11.2: + version "0.11.2" + resolved "https://registry.npmjs.org/apollo-tracing/-/apollo-tracing-0.11.2.tgz#14308b176e021f5e6ec3ee670f8f96e9fbfdb50c" + integrity sha512-QjmRd2ozGD+PfmF6U9w/w6jrclYSBNczN6Bzppr8qA5somEGl5pqdprIZYL28H0IapZiutA3x6p6ZVF/cVX8wA== dependencies: apollo-server-env "^2.4.5" apollo-server-plugin-base "^0.9.1" @@ -11485,18 +11485,18 @@ got@^10.7.0: type-fest "^0.10.0" got@^11.5.2: - version "11.5.2" - resolved "https://registry.npmjs.org/got/-/got-11.5.2.tgz#772e3f3a06d9c7589c7c94dc3c83cdb31ddbf742" - integrity sha512-yUhpEDLeuGiGJjRSzEq3kvt4zJtAcjKmhIiwNp/eUs75tRlXfWcHo5tcBaMQtnjHWC7nQYT5HkY/l0QOQTkVww== + version "11.6.0" + resolved "https://registry.npmjs.org/got/-/got-11.6.0.tgz#4978c78f3cbc3a45ee95381f8bb6efd1db1f4638" + integrity sha512-ErhWb4IUjQzJ3vGs3+RR12NWlBDDkRciFpAkQ1LPUxi6OnwhGj07gQxjPsyIk69s7qMihwKrKquV6VQq7JNYLA== dependencies: - "@sindresorhus/is" "^3.0.0" + "@sindresorhus/is" "^3.1.1" "@szmarczak/http-timer" "^4.0.5" "@types/cacheable-request" "^6.0.1" "@types/responselike" "^1.0.0" cacheable-lookup "^5.0.3" cacheable-request "^7.0.1" decompress-response "^6.0.0" - http2-wrapper "^1.0.0-beta.5.0" + http2-wrapper "^1.0.0-beta.5.2" lowercase-keys "^2.0.0" p-cancelable "^2.0.0" responselike "^2.0.0" @@ -11923,6 +11923,18 @@ highlight.js@~9.15.0, highlight.js@~9.15.1: resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.10.tgz#7b18ed75c90348c045eef9ed08ca1319a2219ad2" integrity sha512-RoV7OkQm0T3os3Dd2VHLNMoaoDVx77Wygln3n9l5YV172XonWG6rgQD3XnF/BuFFZw9A0TJgmMSO8FEWQgvcXw== +history@^4.9.0: + version "4.10.1" + resolved "https://registry.npmjs.org/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" + integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== + dependencies: + "@babel/runtime" "^7.1.2" + loose-envify "^1.2.0" + resolve-pathname "^3.0.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + value-equal "^1.0.1" + history@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08" @@ -11939,7 +11951,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -12188,7 +12200,7 @@ http-signature@~1.2.0: jsprim "^1.2.2" sshpk "^1.7.0" -http2-wrapper@^1.0.0-beta.5.0: +http2-wrapper@^1.0.0-beta.5.2: version "1.0.0-beta.5.2" resolved "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.0-beta.5.2.tgz#8b923deb90144aea65cf834b016a340fc98556f3" integrity sha512-xYz9goEyBnC8XwXDTuC/MZ6t+MrKVQZOk4s7+PaDkwIsQd8IwqvM+0M6bA/2lvG8GHXcPdf+MejTUeO2LCPCeQ== @@ -13218,6 +13230,11 @@ is2@2.0.1: ip-regex "^2.1.0" is-url "^1.2.2" +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -14839,7 +14856,7 @@ longest@^1.0.0: resolved "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc= -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.0, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -15394,6 +15411,14 @@ min-indent@^1.0.0: resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= +mini-create-react-context@^0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz#df60501c83151db69e28eac0ef08b4002efab040" + integrity sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA== + dependencies: + "@babel/runtime" "^7.5.5" + tiny-warning "^1.0.3" + mini-css-extract-plugin@^0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0" @@ -17091,6 +17116,13 @@ path-to-regexp@2.2.1: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz#90b617025a16381a879bc82a38d4e8bdeb2bcf45" integrity sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ== +path-to-regexp@^1.7.0: + version "1.8.0" + resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" + integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== + dependencies: + isarray "0.0.1" + path-type@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -18686,6 +18718,35 @@ react-router-dom@6.0.0-beta.0: prop-types "^15.7.2" react-router "6.0.0-beta.0" +react-router-dom@^5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" + integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + loose-envify "^1.3.1" + prop-types "^15.6.2" + react-router "5.2.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + +react-router@5.2.0: + version "5.2.0" + resolved "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" + integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== + dependencies: + "@babel/runtime" "^7.1.2" + history "^4.9.0" + hoist-non-react-statics "^3.1.0" + loose-envify "^1.3.1" + mini-create-react-context "^0.4.0" + path-to-regexp "^1.7.0" + prop-types "^15.6.2" + react-is "^16.6.0" + tiny-invariant "^1.0.2" + tiny-warning "^1.0.0" + react-router@6.0.0-beta.0: version "6.0.0-beta.0" resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-beta.0.tgz#3e11f39b6ded4412c2fed9e4f989dd4c8156724d" @@ -19398,6 +19459,11 @@ resolve-from@^4.0.0: resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +resolve-pathname@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" + integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== + resolve-url@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" @@ -21504,7 +21570,7 @@ tiny-emitter@^2.0.0: resolved "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== -tiny-invariant@^1.0.6: +tiny-invariant@^1.0.2, tiny-invariant@^1.0.6: version "1.1.0" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== @@ -21526,7 +21592,7 @@ tiny-merge-patch@^0.1.2: resolved "https://registry.npmjs.org/tiny-merge-patch/-/tiny-merge-patch-0.1.2.tgz#2e8ded19c56ea15dbd3ad4ed5db1c8e5ad544c3c" integrity sha1-Lo3tGcVuoV29OtTtXbHI5a1UTDw= -tiny-warning@^1.0.2: +tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== @@ -22429,6 +22495,11 @@ validate.io-number@^1.0.3: resolved "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz#f63ffeda248bf28a67a8d48e0e3b461a1665baf8" integrity sha1-9j/+2iSL8opnqNSODjtGGhZluvg= +value-equal@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" + integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== + vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"