Merge pull request #4058 from backstage/mob/github-app-manager
Add GithubCredentialsProvider for start of GitHub Apps support
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
'@backstage/integration': patch
|
||||
---
|
||||
|
||||
Add support for GitHub Apps authentication for backend plugins.
|
||||
|
||||
`GithubCredentialsProvider` requests and caches GitHub credentials based on a repository or organization url.
|
||||
|
||||
The `GithubCredentialsProvider` class should be considered stateful since tokens will be cached internally.
|
||||
Consecutive calls to get credentials will return the same token, tokens older than 50 minutes will be considered expired and reissued.
|
||||
`GithubCredentialsProvider` will default to the configured access token if no GitHub Apps are configured.
|
||||
|
||||
More information on how to create and configure a GitHub App to use with backstage can be found in the documentation.
|
||||
|
||||
Usage:
|
||||
|
||||
```javascript
|
||||
const credentialsProvider = new GithubCredentialsProvider(config);
|
||||
const { token, headers } = await credentialsProvider.getCredentials({
|
||||
url: 'https://github.com/',
|
||||
});
|
||||
```
|
||||
|
||||
Updates `GithubUrlReader` to use the `GithubCredentialsProvider`.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Using GitHub Apps for Backend Authentication
|
||||
|
||||
Backstage can be configured to use GitHub Apps for backend authentication. This
|
||||
comes with advantages such as higher rate limits and that Backstage can act as
|
||||
an application instead of a user or bot account.
|
||||
|
||||
It also provides a much clearer and better authorization model as a opposed to
|
||||
the OAuth apps and their respective scopes.
|
||||
|
||||
## Caveats
|
||||
|
||||
- It's not possible to have multiple Backstage GitHub Apps installed in the same
|
||||
GitHub organization, to be handled by Backstage. We currently don't check
|
||||
through all the registered GitHub Apps to see which ones are installed for a
|
||||
particular repository. We only respect global Organization installs right now.
|
||||
- App permissions is not managed by Backstage. They're created with some simple
|
||||
default permissions which you are free to change as you need, but you will
|
||||
need to update them in the GitHub web console, not in Backstage right now. The
|
||||
permissions that are defaulted are `metadata:read` and `contents:read`.
|
||||
- The created GitHub App is private by default, this is most likely what you
|
||||
want for github.com but it's recommended to make your application public for
|
||||
GitHub Enterprise in order to share application across your GHE organizations.
|
||||
|
||||
A GitHub app created with `backstage-cli create-github-app` will have read
|
||||
access by default. You have to manually update the GitHub App settings in GitHub
|
||||
to grant the app more permissions if needed.
|
||||
|
||||
### Using the CLI (public GitHub only)
|
||||
|
||||
You can use the `backstage-cli` to create GitHub App' using a manifest file that
|
||||
we provide. This gives us a way to automate some of the work required to create
|
||||
a GitHub app.
|
||||
|
||||
You can read more about the `backstage-cli create-github-app` method
|
||||
[here](../cli/commands.md#create-github-app)
|
||||
|
||||
Once you've gone through the CLI command, it should produce a `yaml` file in the
|
||||
root of the project which you can then use as an `include` in your
|
||||
`app-config.yaml`. You can go ahead and skip to
|
||||
[here](#including-in-integrations-config) if you've got to this part.
|
||||
|
||||
### GitHub Enterprise
|
||||
|
||||
You have to create the GitHub Application manually using these
|
||||
[instructions](https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app)
|
||||
as GitHub Enterprise does not support creation of apps from manifests.
|
||||
|
||||
Once the application is created you have to generate a private key for the
|
||||
application it in a `yaml` file.
|
||||
|
||||
The yaml file must include the following information. Please note that the
|
||||
indentation for the `privateKey` is required.
|
||||
|
||||
```yaml
|
||||
appId: 1
|
||||
clientId: client id
|
||||
clientSecret: client secret
|
||||
webhookSecret: webhook secret
|
||||
privateKey: |
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
...Key content...
|
||||
-----END RSA PRIVATE KEY-----
|
||||
```
|
||||
|
||||
### Including in Integrations Config
|
||||
|
||||
Once the credentials are stored in a yaml file generated by `create-github-app`
|
||||
or manually by following the [GitHub Enterprise](#gitHub-enterprise)
|
||||
instructions, they can be included in the `app-config.yaml` under the
|
||||
`integrations` section.
|
||||
|
||||
Please note that the credentials file is highly sensitive and should NOT be
|
||||
checked into any kind of version control. Instead use your preferred secure
|
||||
method of distributing secrets.
|
||||
|
||||
```yaml
|
||||
integrations:
|
||||
github:
|
||||
- host: github.com
|
||||
apps:
|
||||
- $include: example-backstage-app-credentials.yaml
|
||||
```
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { GithubCredentialsProvider } from '@backstage/integration';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
import fs from 'fs';
|
||||
import { rest } from 'msw';
|
||||
@@ -28,6 +29,18 @@ const treeResponseFactory = ReadTreeResponseFactory.create({
|
||||
});
|
||||
|
||||
describe('GithubUrlReader', () => {
|
||||
const mockCredentialsProvider = ({
|
||||
getCredentials: jest.fn().mockResolvedValue({ headers: {} }),
|
||||
} as unknown) as GithubCredentialsProvider;
|
||||
|
||||
const worker = setupServer();
|
||||
|
||||
msw.setupDefaultHandlers(worker);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('implementation', () => {
|
||||
it('rejects unknown targets', async () => {
|
||||
const processor = new GithubUrlReader(
|
||||
@@ -35,7 +48,7 @@ describe('GithubUrlReader', () => {
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
},
|
||||
{ treeResponseFactory },
|
||||
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
|
||||
);
|
||||
await expect(
|
||||
processor.read('https://not.github.com/apa'),
|
||||
@@ -45,11 +58,52 @@ describe('GithubUrlReader', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('read', () => {
|
||||
it('should use the headers from the credentials provider to the fetch request when doing read', async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
const mockHeaders = {
|
||||
Authorization: 'bearer blah',
|
||||
otherheader: 'something',
|
||||
};
|
||||
|
||||
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
|
||||
headers: mockHeaders,
|
||||
});
|
||||
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://api.github.com/repos/backstage/mock/tree/contents/?ref=repo',
|
||||
(req, res, ctx) => {
|
||||
expect(req.headers.get('authorization')).toBe(
|
||||
mockHeaders.Authorization,
|
||||
);
|
||||
expect(req.headers.get('otherheader')).toBe(
|
||||
mockHeaders.otherheader,
|
||||
);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/x-gzip'),
|
||||
ctx.body('foo'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const processor = new GithubUrlReader(
|
||||
{
|
||||
host: 'ghe.github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
},
|
||||
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
|
||||
);
|
||||
await processor.read(
|
||||
'https://ghe.github.com/backstage/mock/tree/blob/repo',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTree', () => {
|
||||
const worker = setupServer();
|
||||
|
||||
msw.setupDefaultHandlers(worker);
|
||||
|
||||
const repoBuffer = fs.readFileSync(
|
||||
path.resolve('src', 'reading', '__fixtures__', 'repo.tar.gz'),
|
||||
);
|
||||
@@ -74,7 +128,7 @@ describe('GithubUrlReader', () => {
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
},
|
||||
{ treeResponseFactory },
|
||||
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
|
||||
);
|
||||
|
||||
const response = await processor.readTree(
|
||||
@@ -110,7 +164,7 @@ describe('GithubUrlReader', () => {
|
||||
host: 'ghe.github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
},
|
||||
{ treeResponseFactory },
|
||||
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
|
||||
);
|
||||
|
||||
const response = await processor.readTree(
|
||||
@@ -125,13 +179,57 @@ describe('GithubUrlReader', () => {
|
||||
expect(indexMarkdownFile.toString()).toBe('# Test\n');
|
||||
});
|
||||
|
||||
it('should use the headers from the credentials provider to the fetch request', async () => {
|
||||
expect.assertions(2);
|
||||
|
||||
const mockHeaders = {
|
||||
Authorization: 'bearer blah',
|
||||
otherheader: 'something',
|
||||
};
|
||||
|
||||
(mockCredentialsProvider.getCredentials as jest.Mock).mockResolvedValue({
|
||||
headers: mockHeaders,
|
||||
});
|
||||
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://ghe.github.com/backstage/mock/archive/repo.tar.gz',
|
||||
(req, res, ctx) => {
|
||||
expect(req.headers.get('authorization')).toBe(
|
||||
mockHeaders.Authorization,
|
||||
);
|
||||
expect(req.headers.get('otherheader')).toBe(
|
||||
mockHeaders.otherheader,
|
||||
);
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/x-gzip'),
|
||||
ctx.body(repoBuffer),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const processor = new GithubUrlReader(
|
||||
{
|
||||
host: 'ghe.github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
},
|
||||
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
|
||||
);
|
||||
|
||||
await processor.readTree(
|
||||
'https://ghe.github.com/backstage/mock/tree/repo/docs',
|
||||
);
|
||||
});
|
||||
|
||||
it('must specify a branch', async () => {
|
||||
const processor = new GithubUrlReader(
|
||||
{
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
},
|
||||
{ treeResponseFactory },
|
||||
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -147,7 +245,7 @@ describe('GithubUrlReader', () => {
|
||||
host: 'github.com',
|
||||
apiBaseUrl: 'https://api.github.com',
|
||||
},
|
||||
{ treeResponseFactory },
|
||||
{ treeResponseFactory, credentialsProvider: mockCredentialsProvider },
|
||||
);
|
||||
|
||||
const response = await processor.readTree(
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
GitHubIntegrationConfig,
|
||||
readGitHubIntegrationConfigs,
|
||||
getGitHubFileFetchUrl,
|
||||
getGitHubRequestOptions,
|
||||
GithubCredentialsProvider,
|
||||
} from '@backstage/integration';
|
||||
import fetch from 'cross-fetch';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
@@ -42,7 +42,11 @@ export class GithubUrlReader implements UrlReader {
|
||||
config.getOptionalConfigArray('integrations.github') ?? [],
|
||||
);
|
||||
return configs.map(provider => {
|
||||
const reader = new GithubUrlReader(provider, { treeResponseFactory });
|
||||
const credentialsProvider = GithubCredentialsProvider.create(provider);
|
||||
const reader = new GithubUrlReader(provider, {
|
||||
treeResponseFactory,
|
||||
credentialsProvider,
|
||||
});
|
||||
const predicate = (url: URL) => url.host === provider.host;
|
||||
return { reader, predicate };
|
||||
});
|
||||
@@ -50,7 +54,10 @@ export class GithubUrlReader implements UrlReader {
|
||||
|
||||
constructor(
|
||||
private readonly config: GitHubIntegrationConfig,
|
||||
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
|
||||
private readonly deps: {
|
||||
treeResponseFactory: ReadTreeResponseFactory;
|
||||
credentialsProvider: GithubCredentialsProvider;
|
||||
},
|
||||
) {
|
||||
if (!config.apiBaseUrl && !config.rawBaseUrl) {
|
||||
throw new Error(
|
||||
@@ -61,11 +68,17 @@ export class GithubUrlReader implements UrlReader {
|
||||
|
||||
async read(url: string): Promise<Buffer> {
|
||||
const ghUrl = getGitHubFileFetchUrl(url, this.config);
|
||||
const options = getGitHubRequestOptions(this.config);
|
||||
|
||||
const { headers } = await this.deps.credentialsProvider.getCredentials({
|
||||
url,
|
||||
});
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(ghUrl.toString(), options);
|
||||
response = await fetch(ghUrl.toString(), {
|
||||
headers: {
|
||||
...headers,
|
||||
Accept: 'application/vnd.github.v3.raw',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to read ${url}, ${e}`);
|
||||
}
|
||||
@@ -101,12 +114,19 @@ export class GithubUrlReader implements UrlReader {
|
||||
);
|
||||
}
|
||||
|
||||
const { headers } = await this.deps.credentialsProvider.getCredentials({
|
||||
url,
|
||||
});
|
||||
// TODO(Rugvip): use API to fetch URL instead
|
||||
const response = await fetch(
|
||||
new URL(
|
||||
`${protocol}://${resource}/${full_name}/archive/${ref}.tar.gz`,
|
||||
).toString(),
|
||||
getGitHubRequestOptions(this.config),
|
||||
{
|
||||
headers: {
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`;
|
||||
|
||||
Vendored
+10
@@ -35,6 +35,16 @@ export interface Config {
|
||||
apiBaseUrl?: string;
|
||||
/** @visibility frontend */
|
||||
rawBaseUrl?: string;
|
||||
apps?: Array<{
|
||||
appId: number;
|
||||
/** @visiblity secret */
|
||||
privateKey: string;
|
||||
/** @visiblity secret */
|
||||
webhookSecret: string;
|
||||
clientId: string;
|
||||
/** @visiblity secret */
|
||||
clientSecret: string;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
gitlab?: Array<{
|
||||
|
||||
@@ -31,12 +31,16 @@
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.2",
|
||||
"cross-fetch": "^3.0.6",
|
||||
"git-url-parse": "^11.4.3"
|
||||
"git-url-parse": "^11.4.3",
|
||||
"@octokit/rest": "^18.0.12",
|
||||
"@octokit/auth-app": "^2.10.5",
|
||||
"luxon": "^1.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.4.6",
|
||||
"@backstage/test-utils": "^0.1.5",
|
||||
"@types/jest": "^26.0.7",
|
||||
"@types/luxon": "^1.25.0",
|
||||
"msw": "^0.21.2"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const octokit = {
|
||||
apps: {
|
||||
listInstallations: jest.fn(),
|
||||
createInstallationAccessToken: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
jest.doMock('@octokit/rest', () => {
|
||||
class Octokit {
|
||||
constructor() {
|
||||
return octokit;
|
||||
}
|
||||
}
|
||||
return { Octokit };
|
||||
});
|
||||
|
||||
import { GithubCredentialsProvider } from './GithubCredentialsProvider';
|
||||
import { RestEndpointMethodTypes } from '@octokit/rest';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
const github = GithubCredentialsProvider.create({
|
||||
host: 'github.com',
|
||||
apps: [
|
||||
{
|
||||
appId: 1,
|
||||
privateKey: 'privateKey',
|
||||
webhookSecret: '123',
|
||||
clientId: 'CLIENT_ID',
|
||||
clientSecret: 'CLIENT_SECRET',
|
||||
},
|
||||
],
|
||||
token: 'hardcoded_token',
|
||||
});
|
||||
|
||||
describe('GithubCredentialsProvider tests', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
it('create repository specific tokens', async () => {
|
||||
octokit.apps.listInstallations.mockResolvedValueOnce({
|
||||
headers: {
|
||||
etag: '123',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
repository_selection: 'selected',
|
||||
account: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
repository_selection: 'selected',
|
||||
account: {
|
||||
login: 'backstage',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
|
||||
octokit.apps.listInstallations.mockRejectedValue({ status: 304 });
|
||||
|
||||
octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({
|
||||
data: {
|
||||
expires_at: DateTime.local().plus({ hour: 1 }).toString(),
|
||||
token: 'secret_token',
|
||||
},
|
||||
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
|
||||
|
||||
const { token, headers } = await github.getCredentials({
|
||||
url: 'https://github.com/backstage/foobar',
|
||||
});
|
||||
const { token: accessToken2 } = await github.getCredentials({
|
||||
url: 'https://github.com/backstage/foobar',
|
||||
});
|
||||
|
||||
expect(token).toEqual('secret_token');
|
||||
expect(token).toEqual(accessToken2);
|
||||
expect(headers).toEqual({ Authorization: 'Bearer secret_token' });
|
||||
|
||||
// fallback to the configured token if no application is matching
|
||||
await expect(
|
||||
github.getCredentials({
|
||||
url: 'https://github.com/404/foobar',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
headers: {
|
||||
Authorization: 'Bearer hardcoded_token',
|
||||
},
|
||||
token: 'hardcoded_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates tokens for an organization', async () => {
|
||||
octokit.apps.listInstallations.mockResolvedValueOnce({
|
||||
headers: {
|
||||
etag: '123',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
repository_selection: 'all',
|
||||
account: {
|
||||
login: 'backstage',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
|
||||
octokit.apps.listInstallations.mockRejectedValue({ status: 304 });
|
||||
|
||||
octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({
|
||||
data: {
|
||||
expires_at: DateTime.local().plus({ hour: 1 }).toString(),
|
||||
token: 'secret_token',
|
||||
},
|
||||
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
|
||||
|
||||
const { token, headers } = await github.getCredentials({
|
||||
url: 'https://github.com/backstage',
|
||||
});
|
||||
const { token: accessToken2 } = await github.getCredentials({
|
||||
url: 'https://github.com/backstage',
|
||||
});
|
||||
|
||||
expect(headers).toEqual({ Authorization: 'Bearer secret_token' });
|
||||
expect(token).toEqual('secret_token');
|
||||
expect(token).toEqual(accessToken2);
|
||||
});
|
||||
|
||||
it('should fail to issue tokens for an organization when the app is installed for a single repo', async () => {
|
||||
octokit.apps.listInstallations.mockResolvedValueOnce({
|
||||
headers: {
|
||||
etag: '123',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
repository_selection: 'selected',
|
||||
account: {
|
||||
login: 'backstage',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
|
||||
octokit.apps.listInstallations.mockRejectedValue({ status: 304 });
|
||||
|
||||
octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({
|
||||
data: {
|
||||
expires_at: DateTime.local().plus({ hour: 1 }).toString(),
|
||||
token: 'secret_token',
|
||||
},
|
||||
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
|
||||
|
||||
await expect(
|
||||
github.getCredentials({
|
||||
url: 'https://github.com/backstage',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
'The Backstage GitHub application used in the backstage organization must be installed for the entire organization to be able to issue credentials without a specified repository.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if the app is suspended', async () => {
|
||||
octokit.apps.listInstallations.mockResolvedValueOnce({
|
||||
headers: {
|
||||
etag: '123',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
suspended_by: {
|
||||
login: 'admin',
|
||||
},
|
||||
repository_selection: 'all',
|
||||
account: {
|
||||
login: 'backstage',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
|
||||
octokit.apps.listInstallations.mockRejectedValue({ status: 304 });
|
||||
|
||||
await expect(
|
||||
github.getCredentials({
|
||||
url: 'https://github.com/backstage',
|
||||
}),
|
||||
).rejects.toThrow('The GitHub application for backstage is suspended');
|
||||
});
|
||||
|
||||
it('should return the default token when the call to github return a status that is not recognized', async () => {
|
||||
octokit.apps.listInstallations.mockRejectedValue({
|
||||
status: 404,
|
||||
message: 'NotFound',
|
||||
});
|
||||
|
||||
await expect(
|
||||
github.getCredentials({
|
||||
url: 'https://github.com/backstage',
|
||||
}),
|
||||
).rejects.toEqual({ status: 404, message: 'NotFound' });
|
||||
});
|
||||
|
||||
it('should return the default token if no app is configured', async () => {
|
||||
const github = GithubCredentialsProvider.create({
|
||||
host: 'github.com',
|
||||
apps: [],
|
||||
token: 'fallback_token',
|
||||
});
|
||||
|
||||
await expect(
|
||||
github.getCredentials({
|
||||
url: 'https://github.com/404/foobar',
|
||||
}),
|
||||
).resolves.toEqual(expect.objectContaining({ token: 'fallback_token' }));
|
||||
});
|
||||
|
||||
it('should return the configured token if listing installations throws', async () => {
|
||||
const github = GithubCredentialsProvider.create({
|
||||
host: 'github.com',
|
||||
apps: [
|
||||
{
|
||||
appId: 1,
|
||||
privateKey: 'privateKey',
|
||||
webhookSecret: '123',
|
||||
clientId: 'CLIENT_ID',
|
||||
clientSecret: 'CLIENT_SECRET',
|
||||
},
|
||||
],
|
||||
token: 'hardcoded_token',
|
||||
});
|
||||
octokit.apps.listInstallations.mockRejectedValue({ status: 304 });
|
||||
|
||||
await expect(
|
||||
github.getCredentials({
|
||||
url: 'https://github.com/backstage',
|
||||
}),
|
||||
).resolves.toEqual(expect.objectContaining({ token: 'hardcoded_token' }));
|
||||
});
|
||||
|
||||
it('should return undefined if no token or apps are configured', async () => {
|
||||
const github = GithubCredentialsProvider.create({
|
||||
host: 'github.com',
|
||||
});
|
||||
|
||||
await expect(
|
||||
github.getCredentials({
|
||||
url: 'https://github.com/backstage',
|
||||
}),
|
||||
).resolves.toEqual({ headers: undefined, token: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Copyright 2021 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 gitUrlParse from 'git-url-parse';
|
||||
import { GithubAppConfig, GitHubIntegrationConfig } from './config';
|
||||
import { createAppAuth } from '@octokit/auth-app';
|
||||
import { Octokit, RestEndpointMethodTypes } from '@octokit/rest';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
type InstallationData = {
|
||||
installationId: number;
|
||||
suspended: boolean;
|
||||
repositorySelection: 'selected' | 'all';
|
||||
};
|
||||
|
||||
class Cache {
|
||||
private readonly tokenCache = new Map<
|
||||
string,
|
||||
{ token: string; expiresAt: DateTime }
|
||||
>();
|
||||
|
||||
async getOrCreateToken(
|
||||
key: string,
|
||||
supplier: () => Promise<{ token: string; expiresAt: DateTime }>,
|
||||
): Promise<{ accessToken: string }> {
|
||||
const item = this.tokenCache.get(key);
|
||||
if (item && this.isNotExpired(item.expiresAt)) {
|
||||
return { accessToken: item.token };
|
||||
}
|
||||
|
||||
const result = await supplier();
|
||||
this.tokenCache.set(key, result);
|
||||
return { accessToken: result.token };
|
||||
}
|
||||
|
||||
// consider timestamps older than 50 minutes to be expired.
|
||||
private isNotExpired = (date: DateTime) =>
|
||||
date.diff(DateTime.local(), 'minutes').minutes > 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* This accept header is required when calling App APIs in GitHub Enterprise.
|
||||
* It has no effect on calls to github.com and can probably be removed entierly
|
||||
* once GitHub Apps is out of preview.
|
||||
*/
|
||||
const HEADERS = {
|
||||
Accept: 'application/vnd.github.machine-man-preview+json',
|
||||
};
|
||||
|
||||
/**
|
||||
* GithubAppManager issues and caches tokens for a specific GitHub App.
|
||||
*/
|
||||
class GithubAppManager {
|
||||
private readonly appClient: Octokit;
|
||||
private readonly baseAuthConfig: { appId: number; privateKey: string };
|
||||
private installations?: RestEndpointMethodTypes['apps']['listInstallations']['response'];
|
||||
private readonly cache = new Cache();
|
||||
|
||||
constructor(config: GithubAppConfig, baseUrl?: string) {
|
||||
this.baseAuthConfig = {
|
||||
appId: config.appId,
|
||||
privateKey: config.privateKey,
|
||||
};
|
||||
this.appClient = new Octokit({
|
||||
baseUrl,
|
||||
headers: HEADERS,
|
||||
authStrategy: createAppAuth,
|
||||
auth: this.baseAuthConfig,
|
||||
});
|
||||
}
|
||||
|
||||
async getInstallationCredentials(
|
||||
owner: string,
|
||||
repo?: string,
|
||||
): Promise<{ accessToken: string }> {
|
||||
const {
|
||||
installationId,
|
||||
suspended,
|
||||
repositorySelection,
|
||||
} = await this.getInstallationData(owner);
|
||||
if (suspended) {
|
||||
throw new Error(
|
||||
`The GitHub application for ${[owner, repo]
|
||||
.filter(Boolean)
|
||||
.join('/')} is suspended`,
|
||||
);
|
||||
}
|
||||
if (repositorySelection !== 'all' && !repo) {
|
||||
throw new Error(
|
||||
`The Backstage GitHub application used in the ${owner} organization must be installed for the entire organization to be able to issue credentials without a specified repository.`,
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = !repo ? owner : `${owner}/${repo}`;
|
||||
const repositories = repositorySelection !== 'all' ? [repo!] : undefined;
|
||||
|
||||
// Go and grab an access token for the app scoped to a repository if provided, if not use the organisation installation.
|
||||
return this.cache.getOrCreateToken(cacheKey, async () => {
|
||||
const result = await this.appClient.apps.createInstallationAccessToken({
|
||||
installation_id: installationId,
|
||||
headers: HEADERS,
|
||||
repositories,
|
||||
});
|
||||
return {
|
||||
token: result.data.token,
|
||||
expiresAt: DateTime.fromISO(result.data.expires_at),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async getInstallationData(owner: string): Promise<InstallationData> {
|
||||
// List all installations using the last used etag.
|
||||
// Return cached InstallationData if error with status 304 is thrown.
|
||||
try {
|
||||
this.installations = await this.appClient.apps.listInstallations({
|
||||
headers: {
|
||||
'If-None-Match': this.installations?.headers.etag,
|
||||
Accept: HEADERS.Accept,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 304) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const installation = this.installations?.data.find(
|
||||
inst => inst.account?.login === owner,
|
||||
);
|
||||
if (installation) {
|
||||
return {
|
||||
installationId: installation.id,
|
||||
suspended: Boolean(installation.suspended_by),
|
||||
repositorySelection: installation.repository_selection,
|
||||
};
|
||||
}
|
||||
const notFoundError = new Error(
|
||||
`No app installation found for ${owner} in ${this.baseAuthConfig.appId}`,
|
||||
);
|
||||
notFoundError.name = 'NotFoundError';
|
||||
throw notFoundError;
|
||||
}
|
||||
}
|
||||
|
||||
// GithubAppCredentialsMux corresponds to a Github installation which internally could hold several GitHub Apps.
|
||||
export class GithubAppCredentialsMux {
|
||||
private readonly apps: GithubAppManager[];
|
||||
|
||||
constructor(config: GitHubIntegrationConfig) {
|
||||
this.apps =
|
||||
config.apps?.map(ac => new GithubAppManager(ac, config.apiBaseUrl)) ?? [];
|
||||
}
|
||||
|
||||
async getAppToken(owner: string, repo?: string): Promise<string | undefined> {
|
||||
if (this.apps.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
this.apps.map(app =>
|
||||
app.getInstallationCredentials(owner, repo).then(
|
||||
credentials => ({ credentials, error: undefined }),
|
||||
error => ({ credentials: undefined, error }),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const result = results.find(result => result.credentials);
|
||||
if (result) {
|
||||
return result.credentials!.accessToken;
|
||||
}
|
||||
|
||||
const errors = results.map(r => r.error);
|
||||
const notNotFoundError = errors.find(err => err.name !== 'NotFoundError');
|
||||
if (notNotFoundError) {
|
||||
throw notNotFoundError;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type GithubCredentials = {
|
||||
headers?: { [name: string]: string };
|
||||
token?: string;
|
||||
};
|
||||
|
||||
// TODO: Possibly move this to a backend only package so that it's not used in the frontend by mistake
|
||||
export class GithubCredentialsProvider {
|
||||
static create(config: GitHubIntegrationConfig): GithubCredentialsProvider {
|
||||
return new GithubCredentialsProvider(
|
||||
new GithubAppCredentialsMux(config),
|
||||
config.token,
|
||||
);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly githubAppCredentialsMux: GithubAppCredentialsMux,
|
||||
private readonly token?: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns GithubCredentials for requested url.
|
||||
* Consecutive calls to this method with the same url will return cached credentials.
|
||||
* The shortest lifetime for a token returned is 10 minutes.
|
||||
* @param opts containing the organization or repository url
|
||||
* @returns {Promise} of @type {GithubCredentials}.
|
||||
* @example
|
||||
* const { token, headers } = await getCredentials({url: 'github.com/backstage/foobar'})
|
||||
*/
|
||||
async getCredentials(opts: { url: string }): Promise<GithubCredentials> {
|
||||
const parsed = gitUrlParse(opts.url);
|
||||
|
||||
const owner = parsed.owner || parsed.name;
|
||||
const repo = parsed.owner ? parsed.name : undefined;
|
||||
|
||||
let token = await this.githubAppCredentialsMux.getAppToken(owner, repo);
|
||||
if (!token) {
|
||||
token = this.token;
|
||||
}
|
||||
|
||||
return {
|
||||
headers: token
|
||||
? {
|
||||
Authorization: `Bearer ${token}`,
|
||||
}
|
||||
: undefined,
|
||||
token,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,41 @@ export type GitHubIntegrationConfig = {
|
||||
* If no token is specified, anonymous access is used.
|
||||
*/
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* The GitHub Apps configuration to use for requests to this provider.
|
||||
*
|
||||
* If no apps are specified, token or anonymous is used.
|
||||
*/
|
||||
apps?: GithubAppConfig[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The configuration parameters for authenticating a GitHub Application.
|
||||
* A Github Apps configuration can be generated using the `backstage-cli create-github-app` command.
|
||||
*/
|
||||
export type GithubAppConfig = {
|
||||
/**
|
||||
* Unique app identifier, found at https://github.com/organizations/$org/settings/apps/$AppName
|
||||
*/
|
||||
appId: number;
|
||||
/**
|
||||
* The private key is used by the GitHub App integration to authenticate the app.
|
||||
* A private key can be generated from the app at https://github.com/organizations/$org/settings/apps/$AppName
|
||||
*/
|
||||
privateKey: string;
|
||||
/**
|
||||
* Webhook secret can be configured at https://github.com/organizations/$org/settings/apps/$AppName
|
||||
*/
|
||||
webhookSecret: string;
|
||||
/**
|
||||
* Found at https://github.com/organizations/$org/settings/apps/$AppName
|
||||
*/
|
||||
clientId: string;
|
||||
/**
|
||||
* Client secrets can be generated at https://github.com/organizations/$org/settings/apps/$AppName
|
||||
*/
|
||||
clientSecret: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -72,6 +107,13 @@ export function readGitHubIntegrationConfig(
|
||||
let apiBaseUrl = config.getOptionalString('apiBaseUrl');
|
||||
let rawBaseUrl = config.getOptionalString('rawBaseUrl');
|
||||
const token = config.getOptionalString('token');
|
||||
const apps = config.getOptionalConfigArray('apps')?.map(c => ({
|
||||
appId: c.getNumber('appId'),
|
||||
clientId: c.getString('clientId'),
|
||||
clientSecret: c.getString('clientSecret'),
|
||||
webhookSecret: c.getString('webhookSecret'),
|
||||
privateKey: c.getString('privateKey'),
|
||||
}));
|
||||
|
||||
if (!isValidHost(host)) {
|
||||
throw new Error(
|
||||
@@ -91,7 +133,7 @@ export function readGitHubIntegrationConfig(
|
||||
rawBaseUrl = GITHUB_RAW_BASE_URL;
|
||||
}
|
||||
|
||||
return { host, apiBaseUrl, rawBaseUrl, token };
|
||||
return { host, apiBaseUrl, rawBaseUrl, token, apps };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,3 +20,4 @@ export {
|
||||
} from './config';
|
||||
export type { GitHubIntegrationConfig } from './config';
|
||||
export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core';
|
||||
export { GithubCredentialsProvider } from './GithubCredentialsProvider';
|
||||
|
||||
@@ -4660,32 +4660,27 @@
|
||||
dependencies:
|
||||
mkdirp "^1.0.4"
|
||||
|
||||
"@octokit/auth-token@^2.4.0":
|
||||
version "2.4.0"
|
||||
resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.0.tgz#b64178975218b99e4dfe948253f0673cbbb59d9f"
|
||||
integrity sha512-eoOVMjILna7FVQf96iWc3+ZtE/ZT6y8ob8ZzcqKY1ibSQCnu4O/B7pJvzMx5cyZ/RjAff6DAdEb0O0Cjcxidkg==
|
||||
"@octokit/auth-app@^2.10.5":
|
||||
version "2.10.5"
|
||||
resolved "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-2.10.5.tgz#85d69cb96818f5da34bf0b81bb637d3675ad4e9a"
|
||||
integrity sha512-6yXyjtcBWpuPYSdZN8z8IIjGSqkPmiJzdmCdod8at41ANB1FtaKbUIDL5+IkG+svv68NIYs+XORbhBRFXYB3bw==
|
||||
dependencies:
|
||||
"@octokit/types" "^2.0.0"
|
||||
"@octokit/request" "^5.4.11"
|
||||
"@octokit/request-error" "^2.0.0"
|
||||
"@octokit/types" "^6.0.3"
|
||||
"@types/lru-cache" "^5.1.0"
|
||||
deprecation "^2.3.1"
|
||||
lru-cache "^6.0.0"
|
||||
universal-github-app-jwt "^1.0.1"
|
||||
universal-user-agent "^6.0.0"
|
||||
|
||||
"@octokit/auth-token@^2.4.4":
|
||||
"@octokit/auth-token@^2.4.0", "@octokit/auth-token@^2.4.4":
|
||||
version "2.4.4"
|
||||
resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.4.tgz#ee31c69b01d0378c12fd3ffe406030f3d94d3b56"
|
||||
integrity sha512-LNfGu3Ro9uFAYh10MUZVaT7X2CnNm2C8IDQmabx+3DygYIQjs9FwzFAHN/0t6mu5HEPhxcb1XOuxdpY82vCg2Q==
|
||||
dependencies:
|
||||
"@octokit/types" "^6.0.0"
|
||||
|
||||
"@octokit/core@^3.0.0":
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/@octokit/core/-/core-3.1.0.tgz#9c3c9b23f7504668cfa057f143ccbf0c645a0ac9"
|
||||
integrity sha512-yPyQSmxIXLieEIRikk2w8AEtWkFdfG/LXcw1KvEtK3iP0ENZLW/WYQmdzOKqfSaLhooz4CJ9D+WY79C8ZliACw==
|
||||
dependencies:
|
||||
"@octokit/auth-token" "^2.4.0"
|
||||
"@octokit/graphql" "^4.3.1"
|
||||
"@octokit/request" "^5.4.0"
|
||||
"@octokit/types" "^5.0.0"
|
||||
before-after-hook "^2.1.0"
|
||||
universal-user-agent "^5.0.0"
|
||||
|
||||
"@octokit/core@^3.2.3":
|
||||
version "3.2.4"
|
||||
resolved "https://registry.npmjs.org/@octokit/core/-/core-3.2.4.tgz#5791256057a962eca972e31818f02454897fd106"
|
||||
@@ -4707,15 +4702,6 @@
|
||||
is-plain-object "^3.0.0"
|
||||
universal-user-agent "^5.0.0"
|
||||
|
||||
"@octokit/graphql@^4.3.1":
|
||||
version "4.5.1"
|
||||
resolved "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.5.1.tgz#162aed1490320b88ce34775b3f6b8de945529fa9"
|
||||
integrity sha512-qgMsROG9K2KxDs12CO3bySJaYoUu2aic90qpFrv7A8sEBzZ7UFGvdgPKiLw5gOPYEYbS0Xf8Tvf84tJutHPulQ==
|
||||
dependencies:
|
||||
"@octokit/request" "^5.3.0"
|
||||
"@octokit/types" "^5.0.0"
|
||||
universal-user-agent "^5.0.0"
|
||||
|
||||
"@octokit/graphql@^4.5.8":
|
||||
version "4.5.8"
|
||||
resolved "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.5.8.tgz#d42373633c3015d0eafce64a8ce196be167fdd9b"
|
||||
@@ -4742,13 +4728,6 @@
|
||||
dependencies:
|
||||
"@octokit/types" "^2.0.1"
|
||||
|
||||
"@octokit/plugin-paginate-rest@^2.2.0":
|
||||
version "2.2.3"
|
||||
resolved "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.2.3.tgz#a6ad4377e7e7832fb4bdd9d421e600cb7640ac27"
|
||||
integrity sha512-eKTs91wXnJH8Yicwa30jz6DF50kAh7vkcqCQ9D7/tvBAP5KKkg6I2nNof8Mp/65G0Arjsb4QcOJcIEQY+rK1Rg==
|
||||
dependencies:
|
||||
"@octokit/types" "^5.0.0"
|
||||
|
||||
"@octokit/plugin-paginate-rest@^2.6.2":
|
||||
version "2.7.0"
|
||||
resolved "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.7.0.tgz#6bb7b043c246e0654119a6ec4e72a172c9e2c7f3"
|
||||
@@ -4756,12 +4735,7 @@
|
||||
dependencies:
|
||||
"@octokit/types" "^6.0.1"
|
||||
|
||||
"@octokit/plugin-request-log@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.0.tgz#eef87a431300f6148c39a7f75f8cfeb218b2547e"
|
||||
integrity sha512-ywoxP68aOT3zHCLgWZgwUJatiENeHE7xJzYjfz8WI0goynp96wETBF+d95b8g/uL4QmS6owPVlaxiz3wyMAzcw==
|
||||
|
||||
"@octokit/plugin-request-log@^1.0.2":
|
||||
"@octokit/plugin-request-log@^1.0.0", "@octokit/plugin-request-log@^1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.2.tgz#394d59ec734cd2f122431fbaf05099861ece3c44"
|
||||
integrity sha512-oTJSNAmBqyDR41uSMunLQKMX0jmEXbwD1fpz8FG27lScV3RhtGfBa1/BBLym+PxcC16IBlF7KH9vP1BUYxA+Eg==
|
||||
@@ -4774,14 +4748,6 @@
|
||||
"@octokit/types" "^2.0.1"
|
||||
deprecation "^2.3.1"
|
||||
|
||||
"@octokit/plugin-rest-endpoint-methods@4.1.4":
|
||||
version "4.1.4"
|
||||
resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.1.4.tgz#ca60736d761b304fec02a2608caaec2d822e9835"
|
||||
integrity sha512-Y2tVpSa7HjV3DGIQrQOJcReJ2JtcN9FaGr9jDa332Flro923/h3/Iu9e7Y4GilnzfLclHEh5iCQoCkHm7tWOcg==
|
||||
dependencies:
|
||||
"@octokit/types" "^5.4.1"
|
||||
deprecation "^2.3.1"
|
||||
|
||||
"@octokit/plugin-rest-endpoint-methods@4.4.1":
|
||||
version "4.4.1"
|
||||
resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.4.1.tgz#105cf93255432155de078c9efc33bd4e14d1cd63"
|
||||
@@ -4808,21 +4774,7 @@
|
||||
deprecation "^2.0.0"
|
||||
once "^1.4.0"
|
||||
|
||||
"@octokit/request@^5.2.0", "@octokit/request@^5.3.0", "@octokit/request@^5.4.0":
|
||||
version "5.4.5"
|
||||
resolved "https://registry.npmjs.org/@octokit/request/-/request-5.4.5.tgz#8df65bd812047521f7e9db6ff118c06ba84ac10b"
|
||||
integrity sha512-atAs5GAGbZedvJXXdjtKljin+e2SltEs48B3naJjqWupYl2IUBbB/CJisyjbNHcKpHzb3E+OYEZ46G8eakXgQg==
|
||||
dependencies:
|
||||
"@octokit/endpoint" "^6.0.1"
|
||||
"@octokit/request-error" "^2.0.0"
|
||||
"@octokit/types" "^5.0.0"
|
||||
deprecation "^2.0.0"
|
||||
is-plain-object "^3.0.0"
|
||||
node-fetch "^2.3.0"
|
||||
once "^1.4.0"
|
||||
universal-user-agent "^5.0.0"
|
||||
|
||||
"@octokit/request@^5.4.12":
|
||||
"@octokit/request@^5.2.0", "@octokit/request@^5.3.0", "@octokit/request@^5.4.11", "@octokit/request@^5.4.12":
|
||||
version "5.4.12"
|
||||
resolved "https://registry.npmjs.org/@octokit/request/-/request-5.4.12.tgz#b04826fa934670c56b135a81447be2c1723a2ffc"
|
||||
integrity sha512-MvWYdxengUWTGFpfpefBBpVmmEYfkwMoxonIB3sUGp5rhdgwjXL1ejo6JbgzG/QD9B/NYt/9cJX1pxXeSIUCkg==
|
||||
@@ -4858,17 +4810,7 @@
|
||||
once "^1.4.0"
|
||||
universal-user-agent "^4.0.0"
|
||||
|
||||
"@octokit/rest@^18.0.0":
|
||||
version "18.0.5"
|
||||
resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.5.tgz#1f1498dcdc2d85d0f86b8168e4ff5779842b2742"
|
||||
integrity sha512-SPKI24tQXrr1XsnaIjv2x0rl4M5eF1+hj8+vMe3d/exZ7NnL5sTe1BuFyCyJyrc+j1HkXankvgGN9zT0rwBwtg==
|
||||
dependencies:
|
||||
"@octokit/core" "^3.0.0"
|
||||
"@octokit/plugin-paginate-rest" "^2.2.0"
|
||||
"@octokit/plugin-request-log" "^1.0.0"
|
||||
"@octokit/plugin-rest-endpoint-methods" "4.1.4"
|
||||
|
||||
"@octokit/rest@^18.0.12":
|
||||
"@octokit/rest@^18.0.0", "@octokit/rest@^18.0.12":
|
||||
version "18.0.12"
|
||||
resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.12.tgz#278bd41358c56d87c201e787e8adc0cac132503a"
|
||||
integrity sha512-hNRCZfKPpeaIjOVuNJzkEL6zacfZlBPV8vw8ReNeyUkVvbuCvvrrx8K8Gw2eyHHsmd4dPlAxIXIZ9oHhJfkJpw==
|
||||
@@ -4886,16 +4828,9 @@
|
||||
"@types/node" ">= 8"
|
||||
|
||||
"@octokit/types@^5.0.0", "@octokit/types@^5.0.1":
|
||||
version "5.0.1"
|
||||
resolved "https://registry.npmjs.org/@octokit/types/-/types-5.0.1.tgz#5459e9a5e9df8565dcc62c17a34491904d71971e"
|
||||
integrity sha512-GorvORVwp244fGKEt3cgt/P+M0MGy4xEDbckw+K5ojEezxyMDgCaYPKVct+/eWQfZXOT7uq0xRpmrl/+hliabA==
|
||||
dependencies:
|
||||
"@types/node" ">= 8"
|
||||
|
||||
"@octokit/types@^5.4.1":
|
||||
version "5.4.1"
|
||||
resolved "https://registry.npmjs.org/@octokit/types/-/types-5.4.1.tgz#d5d5f2b70ffc0e3f89467c3db749fa87fc3b7031"
|
||||
integrity sha512-OlMlSySBJoJ6uozkr/i03nO5dlYQyE05vmQNZhAh9MyO4DPBP88QlwsDVLmVjIMFssvIZB6WO0ctIGMRG+xsJQ==
|
||||
version "5.5.0"
|
||||
resolved "https://registry.npmjs.org/@octokit/types/-/types-5.5.0.tgz#e5f06e8db21246ca102aa28444cdb13ae17a139b"
|
||||
integrity sha512-UZ1pErDue6bZNjYOotCNveTXArOMZQFG6hKJfOnGnulVCMcVVi7YIIuuR4WfBhjo7zgpmzn/BkPDnUXtNx+PcQ==
|
||||
dependencies:
|
||||
"@types/node" ">= 8"
|
||||
|
||||
@@ -6623,7 +6558,7 @@
|
||||
resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
|
||||
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
|
||||
|
||||
"@types/jsonwebtoken@^8.5.0":
|
||||
"@types/jsonwebtoken@^8.3.3", "@types/jsonwebtoken@^8.5.0":
|
||||
version "8.5.0"
|
||||
resolved "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz#2531d5e300803aa63279b232c014acf780c981c5"
|
||||
integrity sha512-9bVao7LvyorRGZCw0VmH/dr7Og+NdjYSsKAxB43OQoComFbBgsEpoR9JW6+qSq/ogwVBg8GI2MfAlk4SYI4OLg==
|
||||
@@ -6686,6 +6621,16 @@
|
||||
resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
|
||||
integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
|
||||
|
||||
"@types/lru-cache@^5.1.0":
|
||||
version "5.1.0"
|
||||
resolved "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03"
|
||||
integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w==
|
||||
|
||||
"@types/luxon@^1.25.0":
|
||||
version "1.25.0"
|
||||
resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.25.0.tgz#3d6fe591fac874f48dd225cb5660b2b785a21a05"
|
||||
integrity sha512-iIJp2CP6C32gVqI08HIYnzqj55tlLnodIBMCcMf28q9ckqMfMzocCmIzd9JWI/ALLPMUiTkCu1JGv3FFtu6t3g==
|
||||
|
||||
"@types/markdown-to-jsx@^6.11.0":
|
||||
version "6.11.2"
|
||||
resolved "https://registry.npmjs.org/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.2.tgz#05d1aaffbf15be7be12c70535fa4fed65cc7c64f"
|
||||
@@ -17714,6 +17659,11 @@ lru-queue@0.1:
|
||||
dependencies:
|
||||
es5-ext "~0.10.2"
|
||||
|
||||
luxon@^1.25.0:
|
||||
version "1.25.0"
|
||||
resolved "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72"
|
||||
integrity sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ==
|
||||
|
||||
macos-release@^2.2.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.npmjs.org/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f"
|
||||
@@ -25147,6 +25097,14 @@ unist-util-visit@^2.0.0:
|
||||
unist-util-is "^4.0.0"
|
||||
unist-util-visit-parents "^3.0.0"
|
||||
|
||||
universal-github-app-jwt@^1.0.1:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-1.1.0.tgz#0abaa876101cdf1d3e4c546be2768841c0c1b514"
|
||||
integrity sha512-3b+ocAjjz4JTyqaOT+NNBd5BtTuvJTxWElIoeHSVelUV9J3Jp7avmQTdLKCaoqi/5Ox2o/q+VK19TJ233rVXVQ==
|
||||
dependencies:
|
||||
"@types/jsonwebtoken" "^8.3.3"
|
||||
jsonwebtoken "^8.5.1"
|
||||
|
||||
universal-user-agent@^4.0.0:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-4.0.1.tgz#fd8d6cb773a679a709e967ef8288a31fcc03e557"
|
||||
|
||||
Reference in New Issue
Block a user