Merge branch 'master' of github.com:spotify/backstage into shmidt-i/circle-ci-plugin-new-route-api
This commit is contained in:
+3
-4
@@ -129,10 +129,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:
|
||||
|
||||
+104
-53
@@ -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<any>;
|
||||
handler(req: express.Request): Promise<any>;
|
||||
refresh?(refreshToken: string, scope: string): Promise<any>;
|
||||
logout?(): Promise<any>;
|
||||
start(
|
||||
req: express.Request,
|
||||
options: Record<string, string>,
|
||||
): Promise<RedirectInfo>;
|
||||
handler(
|
||||
req: express.Request,
|
||||
): Promise<{
|
||||
response: AuthResponse<OAuthProviderInfo>;
|
||||
refreshToken?: string;
|
||||
}>;
|
||||
refresh?(
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
): Promise<AuthResponse<OAuthProviderInfo>>;
|
||||
logout?(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
```
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -20,7 +20,6 @@ import { Link, makeStyles } from '@material-ui/core';
|
||||
import HomeIcon from '@material-ui/icons/Home';
|
||||
import ExploreIcon from '@material-ui/icons/Explore';
|
||||
import ExtensionIcon from '@material-ui/icons/Extension';
|
||||
import BuildIcon from '@material-ui/icons/BuildRounded';
|
||||
import RuleIcon from '@material-ui/icons/AssignmentTurnedIn';
|
||||
import MapIcon from '@material-ui/icons/MyLocation';
|
||||
import LibraryBooks from '@material-ui/icons/LibraryBooks';
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Router as CircleCIRouter,
|
||||
isPluginApplicableToEntity as isCircleCIAvailable,
|
||||
} from '@backstage/plugin-circleci';
|
||||
import { Router as SentryRouter } from '@backstage/plugin-sentry';
|
||||
import React from 'react';
|
||||
import {
|
||||
EntityPageLayout,
|
||||
@@ -69,6 +70,11 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
title="CI/CD"
|
||||
element={<CICDSwitcher entity={entity} />}
|
||||
/>
|
||||
<EntityPageLayout.Content
|
||||
path="/sentry"
|
||||
title="Sentry"
|
||||
element={<SentryRouter entity={entity} />}
|
||||
/>
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
@@ -84,6 +90,11 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
title="CI/CD"
|
||||
element={<CICDSwitcher entity={entity} />}
|
||||
/>
|
||||
<EntityPageLayout.Content
|
||||
path="/sentry"
|
||||
title="Sentry"
|
||||
element={<SentryRouter entity={entity} />}
|
||||
/>
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = () => (
|
||||
<Grid container>
|
||||
<Grid item xs container>
|
||||
<Grid item xs={12}>
|
||||
<InfoCard title="Trend">
|
||||
<TrendLine data={[0.1, 0.5, 0.9, 1.0]} title="Trend over time" />
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
xs={12}
|
||||
container
|
||||
spacing={2}
|
||||
justify="space-between"
|
||||
direction="row"
|
||||
>
|
||||
<Grid item xs={6}>
|
||||
<ProgressCard
|
||||
title="GKE Usage Score"
|
||||
subheader="This should be above 75%"
|
||||
progress={0.87}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<ProgressCard
|
||||
title="Deployment Score"
|
||||
subheader="This should be above 40%"
|
||||
progress={0.58}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs>
|
||||
<InfoCard
|
||||
title="Information Card"
|
||||
deepLink={{ title: 'LEARN MORE ABOUT RIGHTSIZING FOR GKE', link: '' }}
|
||||
>
|
||||
<b>Rightsize GKE deployment</b>
|
||||
<p>
|
||||
Services are considered underutilized in GKE when the average usage of
|
||||
requested cores is less than 80%.
|
||||
</p>
|
||||
<b>What can I do?</b>
|
||||
<p>
|
||||
Review requested core and limit settings. Check HPA target scaling
|
||||
settings in hpa.yaml. The recommended value for
|
||||
targetCPUUtilizationPercentage is 80.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<b>Definitions</b>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<b>
|
||||
Contact <Link>#cost-awareness</Link> for information and support.
|
||||
</b>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
const ExampleHeader = () => (
|
||||
<Header title="Example" subtitle="This an example plugin">
|
||||
<HeaderLabel label="Owner" value="Owner" />
|
||||
<HeaderLabel label="Lifecycle" value="Lifecycle" />
|
||||
</Header>
|
||||
);
|
||||
|
||||
const ExampleContentHeader = ({ selectedTab }: { selectedTab?: number }) => (
|
||||
<ContentHeader
|
||||
title={selectedTab !== undefined ? tabs[selectedTab].label : 'Header'}
|
||||
>
|
||||
<SupportButton>
|
||||
This Plugin is an example. This text could provide usefull information for
|
||||
the user.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
);
|
||||
|
||||
export const PluginWithData = () => {
|
||||
const [selectedTab, setSelectedTab] = useState<number>(2);
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<ExampleHeader />
|
||||
<HeaderTabs
|
||||
selectedIndex={selectedTab}
|
||||
onChange={index => setSelectedTab(index)}
|
||||
tabs={tabs.map(({ label }, index) => ({
|
||||
id: index.toString(),
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
<Content>
|
||||
<ExampleContentHeader selectedTab={selectedTab} />
|
||||
<DataGrid />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export const PluginWithTable = () => {
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Header title="Example" subtitle="This an example plugin">
|
||||
<HeaderLabel label="Owner" value="Owner" />
|
||||
<HeaderLabel label="Lifecycle" value="Lifecycle" />
|
||||
</Header>
|
||||
<ExampleHeader />
|
||||
<Content>
|
||||
<ContentHeader title="Header">
|
||||
<Button color="primary" variant="contained">
|
||||
Settings
|
||||
</Button>
|
||||
<SupportButton>
|
||||
This Plugin is an example. This text could provide usefull
|
||||
information for the user.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
<ExampleContentHeader />
|
||||
<Table
|
||||
options={{ paging: true, padding: 'dense' }}
|
||||
data={generateTestData(10)}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
|
||||
import { WebMessageResponse } from './types';
|
||||
|
||||
describe('oauth helpers', () => {
|
||||
describe('postMessageResponse', () => {
|
||||
const appOrigin = 'http://localhost:3000';
|
||||
it('should post a message back with payload success', () => {
|
||||
const mockResponse = ({
|
||||
end: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
const data: WebMessageResponse = {
|
||||
type: 'authorization_response',
|
||||
response: {
|
||||
providerInfo: {
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 10,
|
||||
scope: 'email',
|
||||
},
|
||||
profile: {
|
||||
email: 'foo@bar.com',
|
||||
},
|
||||
backstageIdentity: {
|
||||
id: 'a',
|
||||
idToken: 'a.b.c',
|
||||
},
|
||||
},
|
||||
};
|
||||
const jsonData = JSON.stringify(data);
|
||||
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
|
||||
|
||||
postMessageResponse(mockResponse, appOrigin, data);
|
||||
expect(mockResponse.setHeader).toBeCalledTimes(3);
|
||||
expect(mockResponse.end).toBeCalledTimes(1);
|
||||
expect(mockResponse.end).toBeCalledWith(
|
||||
expect.stringContaining(base64Data),
|
||||
);
|
||||
});
|
||||
|
||||
it('should post a message back with payload error', () => {
|
||||
const mockResponse = ({
|
||||
end: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
const data: WebMessageResponse = {
|
||||
type: 'authorization_response',
|
||||
error: new Error('Unknown error occured'),
|
||||
};
|
||||
const jsonData = JSON.stringify(data);
|
||||
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
|
||||
|
||||
postMessageResponse(mockResponse, appOrigin, data);
|
||||
expect(mockResponse.setHeader).toBeCalledTimes(3);
|
||||
expect(mockResponse.end).toBeCalledTimes(1);
|
||||
expect(mockResponse.end).toBeCalledWith(
|
||||
expect.stringContaining(base64Data),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensuresXRequestedWith', () => {
|
||||
it('should return false if no header present', () => {
|
||||
const mockRequest = ({
|
||||
header: () => jest.fn(),
|
||||
} as unknown) as express.Request;
|
||||
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if header present with incorrect value', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'INVALID',
|
||||
} as unknown) as express.Request;
|
||||
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if header present with correct value', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
} as unknown) as express.Request;
|
||||
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { WebMessageResponse } from './types';
|
||||
|
||||
export const postMessageResponse = (
|
||||
res: express.Response,
|
||||
appOrigin: string,
|
||||
response: WebMessageResponse,
|
||||
) => {
|
||||
const jsonData = JSON.stringify(response);
|
||||
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.setHeader('X-Frame-Options', 'sameorigin');
|
||||
|
||||
// TODO: Make target app origin configurable globally
|
||||
const script = `
|
||||
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
|
||||
window.close()
|
||||
`;
|
||||
const hash = crypto.createHash('sha256').update(script).digest('base64');
|
||||
res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`);
|
||||
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<script>${script}</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
};
|
||||
|
||||
export const ensuresXRequestedWith = (req: express.Request) => {
|
||||
const requiredHeader = req.header('X-Requested-With');
|
||||
|
||||
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AuthResponse } from '../../providers/types';
|
||||
|
||||
/**
|
||||
* Payload sent as a post message after the auth request is complete.
|
||||
* If successful then has a valid payload with Auth information else contains an error.
|
||||
*/
|
||||
export type WebMessageResponse =
|
||||
| {
|
||||
type: 'authorization_response';
|
||||
response: AuthResponse<unknown>;
|
||||
}
|
||||
| {
|
||||
type: 'authorization_response';
|
||||
error: Error;
|
||||
};
|
||||
+11
-159
@@ -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,
|
||||
});
|
||||
+14
-111
@@ -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(`
|
||||
<html>
|
||||
<body>
|
||||
<script>${script}</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
};
|
||||
|
||||
export const ensuresXRequestedWith = (req: express.Request) => {
|
||||
const requiredHeader = req.header('X-Requested-With');
|
||||
|
||||
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
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.
|
||||
+55
-33
@@ -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<string, AuthProviderRouteHandlers>();
|
||||
|
||||
export class EnvironmentHandler implements AuthProviderRouteHandlers {
|
||||
constructor(
|
||||
private readonly providerId: string,
|
||||
private readonly providers: EnvironmentHandlers,
|
||||
private readonly envIdentifier: EnvironmentIdentifierFn,
|
||||
) {}
|
||||
|
||||
private getProviderForEnv(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): AuthProviderRouteHandlers | undefined {
|
||||
const env: string | undefined = this.envIdentifier(req);
|
||||
|
||||
if (!env) {
|
||||
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.
|
||||
<br>
|
||||
<br>
|
||||
For this flow to work you need to supply a valid configuration for the "${env}" environment of the "${this.providerId}" provider.`,
|
||||
);
|
||||
return undefined;
|
||||
return new OAuthEnvironmentHandler(handlers);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly handlers: Map<string, AuthProviderRouteHandlers>,
|
||||
) {}
|
||||
|
||||
async start(req: express.Request, res: express.Response): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req, res);
|
||||
await provider?.start(req, res);
|
||||
@@ -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.
|
||||
<br>
|
||||
<br>
|
||||
For this flow to work you need to supply a valid configuration for the "${env}" environment of provider.`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.handlers.get(env);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { verifyNonce, encodeState } from './helpers';
|
||||
|
||||
describe('OAuthProvider Utils', () => {
|
||||
describe('verifyNonce', () => {
|
||||
it('should throw error if cookie nonce missing', () => {
|
||||
const state = { nonce: 'NONCE', env: 'development' };
|
||||
const mockRequest = ({
|
||||
cookies: {},
|
||||
query: {
|
||||
state: encodeState(state),
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
expect(() => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).toThrowError('Auth response is missing cookie nonce');
|
||||
});
|
||||
|
||||
it('should throw error if state nonce missing', () => {
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
'providera-nonce': 'NONCE',
|
||||
},
|
||||
query: {},
|
||||
} as unknown) as express.Request;
|
||||
expect(() => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).toThrowError('Invalid state passed via request');
|
||||
});
|
||||
|
||||
it('should throw error if nonce mismatch', () => {
|
||||
const state = { nonce: 'NONCEB', env: 'development' };
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
'providera-nonce': 'NONCEA',
|
||||
},
|
||||
query: {
|
||||
state: encodeState(state),
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
expect(() => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).toThrowError('Invalid nonce');
|
||||
});
|
||||
|
||||
it('should not throw any error if nonce matches', () => {
|
||||
const state = { nonce: 'NONCE', env: 'development' };
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
'providera-nonce': 'NONCE',
|
||||
},
|
||||
query: {
|
||||
state: encodeState(state),
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
expect(() => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { OAuthState } from './types';
|
||||
|
||||
export const readState = (stateString: string): OAuthState => {
|
||||
const state = Object.fromEntries(
|
||||
new URLSearchParams(decodeURIComponent(stateString)),
|
||||
);
|
||||
if (
|
||||
!state.nonce ||
|
||||
!state.env ||
|
||||
state.nonce?.length === 0 ||
|
||||
state.env?.length === 0
|
||||
) {
|
||||
throw Error(`Invalid state passed via request`);
|
||||
}
|
||||
return {
|
||||
nonce: state.nonce,
|
||||
env: state.env,
|
||||
};
|
||||
};
|
||||
|
||||
export const encodeState = (state: OAuthState): string => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append('nonce', state.nonce);
|
||||
searchParams.append('env', state.env);
|
||||
|
||||
return encodeURIComponent(searchParams.toString());
|
||||
};
|
||||
|
||||
export const verifyNonce = (req: express.Request, providerId: string) => {
|
||||
const cookieNonce = req.cookies[`${providerId}-nonce`];
|
||||
const state: OAuthState = readState(req.query.state?.toString() ?? '');
|
||||
const stateNonce = state.nonce;
|
||||
|
||||
if (!cookieNonce) {
|
||||
throw new Error('Auth response is missing cookie nonce');
|
||||
}
|
||||
if (stateNonce.length === 0) {
|
||||
throw new Error('Auth response is missing state nonce');
|
||||
}
|
||||
if (cookieNonce !== stateNonce) {
|
||||
throw new Error('Invalid nonce');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler';
|
||||
export { OAuthAdapter } from './OAuthAdapter';
|
||||
export type {
|
||||
OAuthHandlers,
|
||||
OAuthProviderInfo,
|
||||
OAuthProviderOptions,
|
||||
OAuthResponse,
|
||||
OAuthState,
|
||||
} from './types';
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { AuthResponse, RedirectInfo } from '../../providers/types';
|
||||
|
||||
/**
|
||||
* Common options for passport.js-based OAuth providers
|
||||
*/
|
||||
export type OAuthProviderOptions = {
|
||||
/**
|
||||
* Client ID of the auth provider.
|
||||
*/
|
||||
clientId: string;
|
||||
/**
|
||||
* Client Secret of the auth provider.
|
||||
*/
|
||||
clientSecret: string;
|
||||
/**
|
||||
* Callback URL to be passed to the auth provider to redirect to after the user signs in.
|
||||
*/
|
||||
callbackUrl: string;
|
||||
};
|
||||
|
||||
export type OAuthResponse = AuthResponse<OAuthProviderInfo>;
|
||||
|
||||
export type OAuthProviderInfo = {
|
||||
/**
|
||||
* An access token issued for the signed in user.
|
||||
*/
|
||||
accessToken: string;
|
||||
/**
|
||||
* (Optional) Id token issued for the signed in user.
|
||||
*/
|
||||
idToken?: string;
|
||||
/**
|
||||
* Expiry of the access token in seconds.
|
||||
*/
|
||||
expiresInSeconds?: number;
|
||||
/**
|
||||
* Scopes granted for the access token.
|
||||
*/
|
||||
scope: string;
|
||||
/**
|
||||
* A refresh token issued for the signed in user
|
||||
*/
|
||||
refreshToken?: string;
|
||||
};
|
||||
|
||||
export type OAuthState = {
|
||||
/* A type for the serialized value in the `state` parameter of the OAuth authorization flow
|
||||
*/
|
||||
nonce: string;
|
||||
env: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Any OAuth provider needs to implement this interface which has provider specific
|
||||
* handlers for different methods to perform authentication, get access tokens,
|
||||
* refresh tokens and perform sign out.
|
||||
*/
|
||||
export interface OAuthHandlers {
|
||||
/**
|
||||
* This method initiates a sign in request with an auth provider.
|
||||
* @param {express.Request} req
|
||||
* @param options
|
||||
*/
|
||||
start(
|
||||
req: express.Request,
|
||||
options: Record<string, string>,
|
||||
): Promise<RedirectInfo>;
|
||||
|
||||
/**
|
||||
* Handles the redirect from the auth provider when the user has signed in.
|
||||
* @param {express.Request} req
|
||||
*/
|
||||
handler(
|
||||
req: express.Request,
|
||||
): Promise<{
|
||||
response: AuthResponse<OAuthProviderInfo>;
|
||||
refreshToken?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* (Optional) Given a refresh token and scope fetches a new access token from the auth provider.
|
||||
* @param {string} refreshToken
|
||||
* @param {string} scope
|
||||
*/
|
||||
refresh?(
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
): Promise<AuthResponse<OAuthProviderInfo>>;
|
||||
|
||||
/**
|
||||
* (Optional) Sign out of the auth provider.
|
||||
*/
|
||||
logout?(): Promise<void>;
|
||||
}
|
||||
+23
-6
@@ -17,12 +17,13 @@
|
||||
import express from 'express';
|
||||
import passport from 'passport';
|
||||
import jwtDecoder from 'jwt-decode';
|
||||
import {
|
||||
RedirectInfo,
|
||||
RefreshTokenResponse,
|
||||
ProfileInfo,
|
||||
ProviderStrategy,
|
||||
} from '../providers/types';
|
||||
import { ProfileInfo, RedirectInfo } from '../../providers/types';
|
||||
|
||||
export type PassportDoneCallback<Res, Private = never> = (
|
||||
err?: Error,
|
||||
response?: Res,
|
||||
privateInfo?: Private,
|
||||
) => void;
|
||||
|
||||
export const makeProfileInfo = (
|
||||
profile: passport.Profile,
|
||||
@@ -106,6 +107,18 @@ export const executeFrameHandlerStrategy = async <T, PrivateInfo = never>(
|
||||
);
|
||||
};
|
||||
|
||||
type RefreshTokenResponse = {
|
||||
/**
|
||||
* An access token issued for the signed in user.
|
||||
*/
|
||||
accessToken: string;
|
||||
/**
|
||||
* Optionally, the server can issue a new Refresh Token for the user
|
||||
*/
|
||||
refreshToken?: string;
|
||||
params: any;
|
||||
};
|
||||
|
||||
export const executeRefreshTokenStrategy = async (
|
||||
providerStrategy: passport.Strategy,
|
||||
refreshToken: string,
|
||||
@@ -156,6 +169,10 @@ export const executeRefreshTokenStrategy = async (
|
||||
});
|
||||
};
|
||||
|
||||
type ProviderStrategy = {
|
||||
userProfile(accessToken: string, callback: Function): void;
|
||||
};
|
||||
|
||||
export const executeFetchUserProfileStrategy = async (
|
||||
providerStrategy: passport.Strategy,
|
||||
accessToken: string,
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
executeFetchUserProfileStrategy,
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
makeProfileInfo,
|
||||
} from './PassportStrategyHelper';
|
||||
export type { PassportDoneCallback } from './PassportStrategyHelper';
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<string, string>,
|
||||
): Promise<RedirectInfo>;
|
||||
|
||||
url: string;
|
||||
/**
|
||||
* Handles the redirect from the auth provider when the user has signed in.
|
||||
* @param {express.Request} req
|
||||
* Status code to use for the redirect
|
||||
*/
|
||||
handler(
|
||||
req: express.Request,
|
||||
): Promise<{
|
||||
response: AuthResponse<OAuthProviderInfo>;
|
||||
refreshToken?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* (Optional) Given a refresh token and scope fetches a new access token from the auth provider.
|
||||
* @param {string} refreshToken
|
||||
* @param {string} scope
|
||||
*/
|
||||
refresh?(
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
): Promise<AuthResponse<OAuthProviderInfo>>;
|
||||
|
||||
/**
|
||||
* (Optional) Sign out of the auth provider.
|
||||
*/
|
||||
logout?(): Promise<void>;
|
||||
}
|
||||
status?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Any Auth provider needs to implement this interface which handles the routes in the
|
||||
@@ -155,24 +108,18 @@ export interface AuthProviderRouteHandlers {
|
||||
* @param {express.Response} res
|
||||
*/
|
||||
logout?(req: express.Request, res: express.Response): Promise<void>;
|
||||
|
||||
/**
|
||||
*(Optional) A method to identify the environment Context of the Request
|
||||
*
|
||||
*Request
|
||||
*- contains the environment context information encoded in the request
|
||||
* @param {express.Request} req
|
||||
*/
|
||||
identifyEnv?(req: express.Request): string | undefined;
|
||||
}
|
||||
|
||||
export type AuthProviderFactoryOptions = {
|
||||
globalConfig: AuthProviderConfig;
|
||||
config: Config;
|
||||
logger: Logger;
|
||||
tokenIssuer: TokenIssuer;
|
||||
};
|
||||
|
||||
export type AuthProviderFactory = (
|
||||
globalConfig: AuthProviderConfig,
|
||||
env: string,
|
||||
envConfig: Config,
|
||||
logger: Logger,
|
||||
issuer: TokenIssuer,
|
||||
) => AuthProviderRouteHandlers | undefined;
|
||||
options: AuthProviderFactoryOptions,
|
||||
) => AuthProviderRouteHandlers;
|
||||
|
||||
export type AuthResponse<ProviderInfo> = {
|
||||
providerInfo: ProviderInfo;
|
||||
@@ -180,8 +127,6 @@ export type AuthResponse<ProviderInfo> = {
|
||||
backstageIdentity?: BackstageIdentity;
|
||||
};
|
||||
|
||||
export type OAuthResponse = AuthResponse<OAuthProviderInfo>;
|
||||
|
||||
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<unknown>;
|
||||
}
|
||||
| {
|
||||
type: 'authorization_response';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export type PassportDoneCallback<Res, Private = never> = (
|
||||
err?: Error,
|
||||
response?: Res,
|
||||
privateInfo?: Private,
|
||||
) => void;
|
||||
|
||||
export type RedirectInfo = {
|
||||
/**
|
||||
* URL to redirect to
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Status code to use for the redirect
|
||||
*/
|
||||
status?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to display login information to user, i.e. sidebar popup.
|
||||
*
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -104,6 +104,7 @@ export const ResultsFilter = ({ availableTags }: Props) => {
|
||||
>
|
||||
<Checkbox
|
||||
edge="start"
|
||||
color="primary"
|
||||
checked={selectedTags.includes(t)}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core": "^0.1.1-alpha.21",
|
||||
"@backstage/plugin-catalog-model": "^0.1.1-alpha.21",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.21",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.21",
|
||||
"@backstage/theme": "^0.1.1-alpha.21",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
# gcp-projects
|
||||
|
||||
Welcome to the gcp-projects plugin!
|
||||
|
||||
_This plugin was created through the Backstage CLI_
|
||||
|
||||
## Getting started
|
||||
|
||||
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/gcp-projects](http://localhost:3000/gcp-projects).
|
||||
|
||||
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
|
||||
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
|
||||
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin } from '../src/plugin';
|
||||
|
||||
createDevApp()
|
||||
.registerPlugin(plugin)
|
||||
.render();
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@backstage/plugin-gcp-projects",
|
||||
"version": "0.1.1-alpha.21",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli plugin:build",
|
||||
"start": "backstage-cli plugin:serve",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"diff": "backstage-cli plugin:diff",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core": "^0.1.1-alpha.21",
|
||||
"@backstage/theme": "^0.1.1-alpha.21",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use": "^15.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.21",
|
||||
"@backstage/dev-utils": "^0.1.1-alpha.21",
|
||||
"@testing-library/jest-dom": "^5.10.1",
|
||||
"@testing-library/react": "^10.4.1",
|
||||
"@testing-library/user-event": "^12.0.7",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/node": "^12.0.0",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 { createApiRef } from '@backstage/core';
|
||||
import { Project, Operation } from './types';
|
||||
|
||||
export const GCPApiRef = createApiRef<GCPApi>({
|
||||
id: 'plugin.gcpprojects.service',
|
||||
description: 'Used by the GCP Projects plugin to make requests',
|
||||
});
|
||||
|
||||
export type GCPApi = {
|
||||
listProjects: ({ token }: { token: string }) => Promise<Project[]>;
|
||||
getProject: (projectId: string, token: Promise<string>) => Promise<Project>;
|
||||
createProject: (
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
owner: string,
|
||||
token: string,
|
||||
) => Promise<Operation>;
|
||||
};
|
||||
@@ -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<Project[]> {
|
||||
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<string>,
|
||||
): Promise<Project> {
|
||||
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<Operation> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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 (
|
||||
<Content>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<InfoCard title="Create new GCP Project">
|
||||
<SimpleStepper>
|
||||
<SimpleStepperStep title="Project Name">
|
||||
<TextField
|
||||
variant="outlined"
|
||||
name="projectName"
|
||||
label="Project Name"
|
||||
helperText="The name of the new project."
|
||||
inputProps={{ 'aria-label': 'Project Name' }}
|
||||
onChange={e => setProjectName(e.target.value)}
|
||||
value={projectName}
|
||||
fullWidth
|
||||
/>
|
||||
</SimpleStepperStep>
|
||||
<SimpleStepperStep title="Project ID">
|
||||
<TextField
|
||||
variant="outlined"
|
||||
name="projectId"
|
||||
label="projectId"
|
||||
onChange={e => setProjectId(e.target.value)}
|
||||
value={projectId}
|
||||
fullWidth
|
||||
/>
|
||||
</SimpleStepperStep>
|
||||
|
||||
<SimpleStepperStep
|
||||
title="Review"
|
||||
actions={{
|
||||
nextText: 'Confirm',
|
||||
onNext: () => setDisabled(false),
|
||||
}}
|
||||
>
|
||||
<StructuredMetadataTable metadata={metadata} />
|
||||
</SimpleStepperStep>
|
||||
</SimpleStepper>
|
||||
<Button
|
||||
variant="text"
|
||||
data-testid="cancel-button"
|
||||
color="primary"
|
||||
href="/gcp-projects"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
href={`newProject?projectName=${encodeURIComponent(
|
||||
projectName,
|
||||
)},projectId=${encodeURIComponent(projectId)}`}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
);
|
||||
};
|
||||
|
||||
const labels = (
|
||||
<>
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Production" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const NewProjectPage = () => {
|
||||
return (
|
||||
<Page theme={pageTheme.service}>
|
||||
<Header title="New GCP Project" type="tool">
|
||||
{labels}
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<SupportButton>
|
||||
This plugin allows you to view and interact with your gcp projects.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
<Project />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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>(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 <LinearProgress />;
|
||||
} else if (status.error) {
|
||||
return (
|
||||
<Typography variant="h6" color="error">
|
||||
Failed to load build, {status.error.message}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
const details = status.value;
|
||||
|
||||
return (
|
||||
<Table component={Paper} className={classes.table}>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>Name</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{details?.name}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>Project Number</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{details?.projectNumber}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>Project ID</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{details?.projectId}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>State</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{details?.lifecycleState}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>Creation Time</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{details?.createTime}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Typography noWrap>Links</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ButtonGroup
|
||||
variant="text"
|
||||
color="primary"
|
||||
aria-label="text primary button group"
|
||||
>
|
||||
{details?.name && (
|
||||
<Button>
|
||||
<a href={details.name}>GCP</a>
|
||||
</Button>
|
||||
)}
|
||||
{details?.name && (
|
||||
<Button>
|
||||
<a href={details.name}>Logs</a>
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
const labels = (
|
||||
<>
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Production" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const ProjectDetailsPage = () => {
|
||||
return (
|
||||
<Page theme={pageTheme.service}>
|
||||
<Header title="GCP Project Details" type="other">
|
||||
{labels}
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<SupportButton>Support Button</SupportButton>
|
||||
</ContentHeader>
|
||||
<DetailsPage />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 <span>{text}</span>;
|
||||
}
|
||||
return (
|
||||
<Tooltip title={text}>
|
||||
<span>{text.slice(0, max)}...</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const labels = (
|
||||
<>
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Production" />
|
||||
</>
|
||||
);
|
||||
|
||||
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 <LinearProgress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Typography variant="h2" color="error">
|
||||
{error.message}{' '}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table component={Paper}>
|
||||
<Table aria-label="GCP Projects table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Project Number</TableCell>
|
||||
<TableCell>Project ID</TableCell>
|
||||
<TableCell>State</TableCell>
|
||||
<TableCell>Creation Time</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value?.map((project: Project) => (
|
||||
<TableRow key={project.projectId}>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<LongText text={project.name} max={30} />
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<LongText text={project?.projectNumber || 'Error'} max={30} />
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`project?projectId=${encodeURIComponent(
|
||||
project.projectId,
|
||||
)}`}
|
||||
>
|
||||
<Typography color="primary">
|
||||
<LongText text={project.projectId} max={60} />
|
||||
</Typography>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<LongText
|
||||
text={project?.lifecycleState || 'Error'}
|
||||
max={30}
|
||||
/>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography>
|
||||
<LongText text={project?.createTime || 'Error'} max={30} />
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProjectListPage = () => {
|
||||
return (
|
||||
<Page theme={pageTheme.service}>
|
||||
<Header title="GCP Projects" type="tool">
|
||||
{labels}
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="">
|
||||
<Button variant="contained" color="primary" href="/gcp-projects/new">
|
||||
New Project
|
||||
</Button>
|
||||
<SupportButton>All your software catalog entities</SupportButton>
|
||||
</ContentHeader>
|
||||
<PageContents />
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
<WarningPanel title="Sentry plugin:">
|
||||
<pre>{SENTRY_ANNOTATION}</pre> annotation is missing on the entity.
|
||||
</WarningPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<SentryPluginWidget sentryProjectId={projectId} statsFor="24h" />
|
||||
}
|
||||
/>
|
||||
)
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export { Router } from './components/Router';
|
||||
export { SentryPluginWidget as SentryIssuesWidget } from './components/SentryPluginWidget/SentryPluginWidget';
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -94,3 +94,45 @@ export const checkoutGitRepository = async (
|
||||
|
||||
return repositoryTmpPath;
|
||||
};
|
||||
|
||||
// Could be merged with checkoutGitRepository
|
||||
export const checkoutGithubRepository = async (
|
||||
repoUrl: string,
|
||||
): Promise<string> => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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==
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user