Merge pull request #4058 from backstage/mob/github-app-manager

Add GithubCredentialsProvider for start of GitHub Apps support
This commit is contained in:
Johan Haals
2021-01-18 08:10:26 +01:00
committed by GitHub
11 changed files with 851 additions and 103 deletions
+25
View File
@@ -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`.
+82
View File
@@ -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}`;
+10
View File
@@ -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<{
+5 -1
View File
@@ -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,
};
}
}
+43 -1
View File
@@ -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 };
}
/**
+1
View File
@@ -20,3 +20,4 @@ export {
} from './config';
export type { GitHubIntegrationConfig } from './config';
export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core';
export { GithubCredentialsProvider } from './GithubCredentialsProvider';
+43 -85
View File
@@ -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"