Merge branch 'master' of github.com:spotify/backstage into shmidt-i/circle-ci-plugin-new-route-api

This commit is contained in:
Ivan Shmidt
2020-09-04 16:07:30 +02:00
65 changed files with 2311 additions and 912 deletions
+3 -4
View File
@@ -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
View File
@@ -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;
```
+27 -22
View File
@@ -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`
+1
View File
@@ -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",
+2
View File
@@ -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>
);
+1
View File
@@ -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';
+133 -16
View File
@@ -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;
};
@@ -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,
});
@@ -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.
@@ -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';
+111
View File
@@ -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>;
}
@@ -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);
}
};
+15 -167
View File
@@ -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();
+1
View File
@@ -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
+1 -1
View File
@@ -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",
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+13
View File
@@ -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.
+22
View File
@@ -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();
+47
View File
@@ -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"
]
}
+34
View File
@@ -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>;
};
+124
View File
@@ -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;
}
}
+19
View File
@@ -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';
+41
View File
@@ -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';
+18
View File
@@ -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';
+23
View File
@@ -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();
});
});
+42
View File
@@ -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);
},
});
+19
View File
@@ -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();
+2
View File
@@ -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"
+46
View File
@@ -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>
);
};
+1
View File
@@ -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;
};
+75 -4
View File
@@ -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"