From 42a1591b6597113b62be9285136626157b58f203 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 7 Jan 2021 17:19:23 +0100 Subject: [PATCH 01/31] WIP: Github App manager --- packages/integration/src/github/githubApps.ts | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 packages/integration/src/github/githubApps.ts diff --git a/packages/integration/src/github/githubApps.ts b/packages/integration/src/github/githubApps.ts new file mode 100644 index 0000000000..bd2d6853a8 --- /dev/null +++ b/packages/integration/src/github/githubApps.ts @@ -0,0 +1,245 @@ +/* + * 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 { GithubAppConfig, GitHubIntegrationConfig } from './config'; +import { createAppAuth } from '@octokit/auth-app'; +import { Octokit } from '@octokit/rest'; +import gitUrlParse from 'git-url-parse'; + +type InstallationData = { + installationId: number; + suspended: boolean; + repositorySelection: 'selected' | 'all'; +}; + +type InstallationRepoData = { + etag: string; + repos: Set; +}; + +// for each app +class GithubAppManager { + private readonly appClient: Octokit; + private readonly baseAuthConfig: { appId: number; privateKey: string }; + private readonly installationDatas = new Map(); + private readonly installationRepoDatas = new Map< + number, + InstallationRepoData + >(); + + private installationsEtag?: string; + + constructor(config: GithubAppConfig) { + this.baseAuthConfig = { + appId: config.appId, + privateKey: config.privateKey, + }; + this.appClient = new Octokit({ + authStrategy: createAppAuth, + auth: this.baseAuthConfig, + }); + } + + getInstallationClient(installationId: number): Octokit { + return new Octokit({ + authStrategy: createAppAuth, + auth: { + ...this.baseAuthConfig, + installationId, + }, + }); + } + + async getInstallationCredentials( + owner: string, + repo: string, + ): Promise<{ accessToken: string }> { + const { + installationId, + suspended, + repositorySelection, + } = await this.getInstallationData(owner); + if (suspended) { + throw new Error(`The app for ${owner}/${repo} is suspended`); + } + + const auth = createAppAuth({ ...this.baseAuthConfig, installationId }); + + const { token } = await auth({ type: 'installation' }); + + if (repositorySelection === 'all') { + return { accessToken: token }; + } + + // const octokit = new Octokit({ auth: token }); + const res = await this.getInstallationClient( + installationId, + ).apps.createInstallationAccessToken({ + installation_id: installationId, + repositories: [repo], + }); + // token: 'v1.186839cac1afb9236d3b41dffee02ffbcf17861b', + // expires_at: '2021-01-07T17:12:50Z', + // permissions: { contents: 'read', metadata: 'read' }, + // repository_selection: 'selected', + // repositories: [ [Object] ] + return { accessToken: res.data.token }; + + // const hasRepo = await this.installationHasRepo(installationId, repo, token); + // if (!hasRepo) { + // const error = new Error( + // `No app installation found for ${owner}/${repo} in ${this.baseAuthConfig.appId}`, + // ); + // error.name = 'NotFoundError'; + // throw error; + // } + + // return { accessToken: token }; + } + + private async installationHasRepo(id: number, repo: string, token: string) { + const octokit = new Octokit({ auth: token }); + + const installationRepoData = this.installationRepoDatas.get(id); + + let repos: Set; + try { + const res = await octokit.apps.listReposAccessibleToInstallation({ + headers: { + 'If-None-Match': installationRepoData?.etag, + }, + }); + repos = new Set(res.data.repositories.map(repo => repo.name)); + this.installationRepoDatas.set(id, { + etag: res.headers.etag, + repos, + }); + } catch (error) { + if (error.status !== 304) { + throw error; + } + repos = installationRepoData!.repos; + } + + return repos.has(repo); + } + + private async getInstallationData(owner: string): Promise { + try { + const installations = await this.appClient.apps.listInstallations({ + headers: { + 'If-None-Match': this.installationsEtag, + }, + }); + this.installationsEtag = installations.headers.etag; + + const installation = installations.data.find( + inst => inst.account?.login === owner, + ); + + if (installation) { + const data = { + installationId: installation.id, + suspended: Boolean(installation.suspended_by), + repositorySelection: installation.repository_selection, + }; + this.installationDatas.set(owner, data); + return data; + } + } catch (error) { + if (error.status !== 304) { + throw error; + } + const data = this.installationDatas.get(owner); + if (data) { + return data; + } + } + + const notFoundError = new Error( + `No app installation found for ${owner} in ${this.baseAuthConfig.appId}`, + ); + notFoundError.name = 'NotFoundError'; + throw notFoundError; + } +} + +// for each github installation +class GithubIntegration { + private readonly apps: GithubAppManager[]; + + constructor(config: GitHubIntegrationConfig) { + this.apps = config.apps?.map(ac => new GithubAppManager(ac)) ?? []; + } + + async getCredentialsForAppInstallation( + owner: string, + repo: string, + ): Promise<{ accessToken: string }> { + 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; + } + + const errors = results.map(r => r.error); + const notNotFoundError = errors.find(err => err.name !== 'NotFoundError'); + if (notNotFoundError) { + throw notNotFoundError; + } + const notFoundError = new Error( + `No app installation found for ${owner}/${repo}`, + ); + notFoundError.name = 'NotFoundError'; + throw notFoundError; + } +} + +export class GithubAppAuthProvider { + private readonly integrations: Map; + + constructor(configs: GitHubIntegrationConfig[]) { + this.integrations = new Map( + configs.map(config => [config.host, new GithubIntegration(config)]), + ); + } + + // getCredentials('github.com/backstage/somerepo') + async getCredentials(url: string): Promise<{ accessToken: string }> { + const parsed = gitUrlParse(url); + + const host = parsed.source; + const owner = parsed.owner; + const repo = parsed.name; + + const integration = await this.integrations.get(host); + const credentials = await integration?.getCredentialsForAppInstallation( + owner, + repo, + ); + if (!credentials) { + throw new Error(`No app installation found for ${owner}/${repo}`); + } + return credentials; + } +} From 8c6f35528ccc111c4b6ce9bb7aa6bedd4b5dba3f Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 8 Jan 2021 09:33:23 +0100 Subject: [PATCH 02/31] Remove installationHasRepo --- packages/integration/src/github/githubApps.ts | 70 ++++--------------- 1 file changed, 14 insertions(+), 56 deletions(-) diff --git a/packages/integration/src/github/githubApps.ts b/packages/integration/src/github/githubApps.ts index bd2d6853a8..fca380f51c 100644 --- a/packages/integration/src/github/githubApps.ts +++ b/packages/integration/src/github/githubApps.ts @@ -25,21 +25,20 @@ type InstallationData = { repositorySelection: 'selected' | 'all'; }; -type InstallationRepoData = { - etag: string; - repos: Set; -}; +class Cache { + private readonly entries = new Map(); + getToken(key: string) {} +} // for each app class GithubAppManager { private readonly appClient: Octokit; private readonly baseAuthConfig: { appId: number; privateKey: string }; private readonly installationDatas = new Map(); - private readonly installationRepoDatas = new Map< - number, - InstallationRepoData - >(); + // private readonly repoTokenCache = new Cache<{ token: string; exp: Date }>( + // ({ exp }) => isInThePast(exp), + // ); private installationsEtag?: string; constructor(config: GithubAppConfig) { @@ -76,18 +75,16 @@ class GithubAppManager { throw new Error(`The app for ${owner}/${repo} is suspended`); } - const auth = createAppAuth({ ...this.baseAuthConfig, installationId }); - - const { token } = await auth({ type: 'installation' }); - if (repositorySelection === 'all') { + const auth = createAppAuth({ ...this.baseAuthConfig, installationId }); + + const { token } = await auth({ type: 'installation' }); + console.log('DEBUG: token =', token); + return { accessToken: token }; } - // const octokit = new Octokit({ auth: token }); - const res = await this.getInstallationClient( - installationId, - ).apps.createInstallationAccessToken({ + const res = await this.appClient.apps.createInstallationAccessToken({ installation_id: installationId, repositories: [repo], }); @@ -97,44 +94,6 @@ class GithubAppManager { // repository_selection: 'selected', // repositories: [ [Object] ] return { accessToken: res.data.token }; - - // const hasRepo = await this.installationHasRepo(installationId, repo, token); - // if (!hasRepo) { - // const error = new Error( - // `No app installation found for ${owner}/${repo} in ${this.baseAuthConfig.appId}`, - // ); - // error.name = 'NotFoundError'; - // throw error; - // } - - // return { accessToken: token }; - } - - private async installationHasRepo(id: number, repo: string, token: string) { - const octokit = new Octokit({ auth: token }); - - const installationRepoData = this.installationRepoDatas.get(id); - - let repos: Set; - try { - const res = await octokit.apps.listReposAccessibleToInstallation({ - headers: { - 'If-None-Match': installationRepoData?.etag, - }, - }); - repos = new Set(res.data.repositories.map(repo => repo.name)); - this.installationRepoDatas.set(id, { - etag: res.headers.etag, - repos, - }); - } catch (error) { - if (error.status !== 304) { - throw error; - } - repos = installationRepoData!.repos; - } - - return repos.has(repo); } private async getInstallationData(owner: string): Promise { @@ -177,7 +136,6 @@ class GithubAppManager { } } -// for each github installation class GithubIntegration { private readonly apps: GithubAppManager[]; @@ -199,7 +157,7 @@ class GithubIntegration { ); const result = results.find(result => result.credentials); if (result) { - return result.credentials; + return result.credentials!; } const errors = results.map(r => r.error); From 6437f0adf8153b77249597744f8cf83149803cc5 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 8 Jan 2021 11:17:39 +0100 Subject: [PATCH 03/31] wip --- packages/integration/src/github/githubApps.ts | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/packages/integration/src/github/githubApps.ts b/packages/integration/src/github/githubApps.ts index fca380f51c..cd4002ef13 100644 --- a/packages/integration/src/github/githubApps.ts +++ b/packages/integration/src/github/githubApps.ts @@ -18,6 +18,7 @@ import { GithubAppConfig, GitHubIntegrationConfig } from './config'; import { createAppAuth } from '@octokit/auth-app'; import { Octokit } from '@octokit/rest'; import gitUrlParse from 'git-url-parse'; +import moment from 'moment'; type InstallationData = { installationId: number; @@ -25,20 +26,16 @@ type InstallationData = { repositorySelection: 'selected' | 'all'; }; -class Cache { - private readonly entries = new Map(); - - getToken(key: string) {} -} -// for each app +// GithubAppManager issues tokens for a speicifc GitHub App class GithubAppManager { private readonly appClient: Octokit; private readonly baseAuthConfig: { appId: number; privateKey: string }; private readonly installationDatas = new Map(); + private readonly tokenCache = new Map< + string, + { accessToken: string; exp: Date } + >(); - // private readonly repoTokenCache = new Cache<{ token: string; exp: Date }>( - // ({ exp }) => isInThePast(exp), - // ); private installationsEtag?: string; constructor(config: GithubAppConfig) { @@ -52,15 +49,8 @@ class GithubAppManager { }); } - getInstallationClient(installationId: number): Octokit { - return new Octokit({ - authStrategy: createAppAuth, - auth: { - ...this.baseAuthConfig, - installationId, - }, - }); - } + private lessThanOneHourAgo = (date: Date) => + moment(date).isAfter(moment().subtract(1, 'hours')); async getInstallationCredentials( owner: string, @@ -75,28 +65,38 @@ class GithubAppManager { throw new Error(`The app for ${owner}/${repo} is suspended`); } + // App is installed in the entire org if (repositorySelection === 'all') { const auth = createAppAuth({ ...this.baseAuthConfig, installationId }); - const { token } = await auth({ type: 'installation' }); - console.log('DEBUG: token =', token); - return { accessToken: token }; } + // App is not installed org wide which requires a specific app token. + const cacheKey = `${owner}/${repo}`; + if (this.tokenCache.has(cacheKey)) { + const item = this.tokenCache.get(cacheKey); + if (this.lessThanOneHourAgo(item?.exp!)) { + return { + accessToken: item?.accessToken!, + }; + } + } + const res = await this.appClient.apps.createInstallationAccessToken({ installation_id: installationId, repositories: [repo], }); - // token: 'v1.186839cac1afb9236d3b41dffee02ffbcf17861b', - // expires_at: '2021-01-07T17:12:50Z', - // permissions: { contents: 'read', metadata: 'read' }, - // repository_selection: 'selected', - // repositories: [ [Object] ] + this.tokenCache.set(cacheKey, { + accessToken: res.data.token, + exp: new Date(res.data.expires_at), + }); return { accessToken: res.data.token }; } private async getInstallationData(owner: string): Promise { + // List all installations using the last used etag. + // Return cached InstallationData if error with status 304 is thrown. try { const installations = await this.appClient.apps.listInstallations({ headers: { @@ -136,6 +136,7 @@ class GithubAppManager { } } +// GithubIntegration corresponds to a Github installation which internally could hold several GitHub Apps. class GithubIntegration { private readonly apps: GithubAppManager[]; From 5d20d4bad8cb813151d0750ac87329c133b1d4e6 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 8 Jan 2021 13:11:16 +0100 Subject: [PATCH 04/31] Store installations response --- packages/integration/src/github/githubApps.ts | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/integration/src/github/githubApps.ts b/packages/integration/src/github/githubApps.ts index cd4002ef13..daf96429b9 100644 --- a/packages/integration/src/github/githubApps.ts +++ b/packages/integration/src/github/githubApps.ts @@ -16,7 +16,7 @@ import { GithubAppConfig, GitHubIntegrationConfig } from './config'; import { createAppAuth } from '@octokit/auth-app'; -import { Octokit } from '@octokit/rest'; +import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; import gitUrlParse from 'git-url-parse'; import moment from 'moment'; @@ -30,14 +30,12 @@ type InstallationData = { class GithubAppManager { private readonly appClient: Octokit; private readonly baseAuthConfig: { appId: number; privateKey: string }; - private readonly installationDatas = new Map(); + private installations?: RestEndpointMethodTypes['apps']['listInstallations']['response']; private readonly tokenCache = new Map< string, { accessToken: string; exp: Date } >(); - private installationsEtag?: string; - constructor(config: GithubAppConfig) { this.baseAuthConfig = { appId: config.appId, @@ -67,7 +65,10 @@ class GithubAppManager { // App is installed in the entire org if (repositorySelection === 'all') { - const auth = createAppAuth({ ...this.baseAuthConfig, installationId }); + const auth = createAppAuth({ + ...this.baseAuthConfig, + installationId, + }); const { token } = await auth({ type: 'installation' }); return { accessToken: token }; } @@ -97,35 +98,31 @@ class GithubAppManager { private async getInstallationData(owner: string): Promise { // List all installations using the last used etag. // Return cached InstallationData if error with status 304 is thrown. + let installation; try { - const installations = await this.appClient.apps.listInstallations({ + this.installations = await this.appClient.apps.listInstallations({ headers: { - 'If-None-Match': this.installationsEtag, + 'If-None-Match': this.installations?.headers.etag, }, }); - this.installationsEtag = installations.headers.etag; - const installation = installations.data.find( + installation = this.installations.data.find( inst => inst.account?.login === owner, ); - - if (installation) { - const data = { - installationId: installation.id, - suspended: Boolean(installation.suspended_by), - repositorySelection: installation.repository_selection, - }; - this.installationDatas.set(owner, data); - return data; - } } catch (error) { if (error.status !== 304) { throw error; } - const data = this.installationDatas.get(owner); - if (data) { - return data; - } + 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( From 25a5d6bf2f7dd0d240bb482ae984b1fc53d79e5e Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Mon, 11 Jan 2021 14:28:56 +0100 Subject: [PATCH 05/31] Refactor GitHubAppManager, add deps & config --- packages/integration/config.d.ts | 10 ++ packages/integration/package.json | 5 +- packages/integration/src/github/config.ts | 14 ++ packages/integration/src/github/githubApps.ts | 77 ++++++----- yarn.lock | 120 +++++------------- 5 files changed, 109 insertions(+), 117 deletions(-) diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index a03a07409b..6a9fd62995 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -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<{ diff --git a/packages/integration/package.json b/packages/integration/package.json index 258c5c61c6..52dc52a658 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -31,7 +31,10 @@ "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", + "moment": "^2.29.1" }, "devDependencies": { "@backstage/cli": "^0.4.5", diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index 22e8ad57d8..527e287419 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -58,8 +58,22 @@ 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 is specified, token or anonymous is used. + */ + apps?: GithubAppConfig[]; }; +export type GithubAppConfig = { + appId: number; + privateKey: string; + webhookSecret: string; + clientId: string; + clientSecret: string; +}; /** * Reads a single GitHub integration config. * diff --git a/packages/integration/src/github/githubApps.ts b/packages/integration/src/github/githubApps.ts index daf96429b9..75f83e8662 100644 --- a/packages/integration/src/github/githubApps.ts +++ b/packages/integration/src/github/githubApps.ts @@ -19,6 +19,7 @@ import { createAppAuth } from '@octokit/auth-app'; import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; import gitUrlParse from 'git-url-parse'; import moment from 'moment'; +import { InstallationAccessTokenAuthentication } from '@octokit/auth-app/dist-types/types'; type InstallationData = { installationId: number; @@ -26,15 +27,37 @@ type InstallationData = { repositorySelection: 'selected' | 'all'; }; +class Cache { + private readonly tokenCache = new Map< + string, + { token: string; expiresAt: Date } + >(); + + async getToken( + key: string, + fn: () => Promise<{ token: string; expiresAt: Date }>, + ): Promise<{ accessToken: string }> { + const item = this.tokenCache.get(key); + if (item && this.isNotExpired(item.expiresAt)) { + return { accessToken: item.token }; + } + + const result = await fn(); + this.tokenCache.set(key, result); + return { accessToken: result.token }; + } + + // consider timestamps older than 50 minutes to be expired. + private isNotExpired = (date: Date) => + moment(date).isAfter(moment().subtract(50, 'minutes')); +} + // GithubAppManager issues tokens for a speicifc GitHub App class GithubAppManager { private readonly appClient: Octokit; private readonly baseAuthConfig: { appId: number; privateKey: string }; private installations?: RestEndpointMethodTypes['apps']['listInstallations']['response']; - private readonly tokenCache = new Map< - string, - { accessToken: string; exp: Date } - >(); + private readonly cache = new Cache(); constructor(config: GithubAppConfig) { this.baseAuthConfig = { @@ -47,9 +70,6 @@ class GithubAppManager { }); } - private lessThanOneHourAgo = (date: Date) => - moment(date).isAfter(moment().subtract(1, 'hours')); - async getInstallationCredentials( owner: string, repo: string, @@ -65,34 +85,31 @@ class GithubAppManager { // App is installed in the entire org if (repositorySelection === 'all') { - const auth = createAppAuth({ - ...this.baseAuthConfig, - installationId, + return this.cache.getToken(owner, async () => { + const auth = createAppAuth({ + ...this.baseAuthConfig, + installationId, + }); + const result = await auth({ type: 'installation' }); + const { + token, + expiresAt, + } = result as InstallationAccessTokenAuthentication; + return { token, expiresAt: new Date(expiresAt) }; }); - const { token } = await auth({ type: 'installation' }); - return { accessToken: token }; } // App is not installed org wide which requires a specific app token. - const cacheKey = `${owner}/${repo}`; - if (this.tokenCache.has(cacheKey)) { - const item = this.tokenCache.get(cacheKey); - if (this.lessThanOneHourAgo(item?.exp!)) { - return { - accessToken: item?.accessToken!, - }; - } - } - - const res = await this.appClient.apps.createInstallationAccessToken({ - installation_id: installationId, - repositories: [repo], + return this.cache.getToken(`${owner}/${repo}`, async () => { + const result = await this.appClient.apps.createInstallationAccessToken({ + installation_id: installationId, + repositories: [repo], + }); + return { + token: result.data.token, + expiresAt: new Date(result.data.expires_at), + }; }); - this.tokenCache.set(cacheKey, { - accessToken: res.data.token, - exp: new Date(res.data.expires_at), - }); - return { accessToken: res.data.token }; } private async getInstallationData(owner: string): Promise { diff --git a/yarn.lock b/yarn.lock index 5182e6abb0..ea4d11c6d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4661,32 +4661,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" @@ -4708,15 +4703,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" @@ -4743,13 +4729,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" @@ -4757,12 +4736,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== @@ -4775,14 +4749,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" @@ -4809,21 +4775,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== @@ -4859,17 +4811,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== @@ -4887,16 +4829,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" @@ -6631,7 +6566,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== @@ -6694,6 +6629,11 @@ 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/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" @@ -18484,7 +18424,7 @@ modify-values@^1.0.0: resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -moment@^2.25.3, moment@^2.26.0, moment@^2.27.0: +moment@^2.25.3, moment@^2.26.0, moment@^2.27.0, moment@^2.29.1: version "2.29.1" resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== @@ -25155,6 +25095,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" From 04ad29ca4e0f274bdb8cfc004077ebf39e88e377 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Tue, 12 Jan 2021 16:42:39 +0100 Subject: [PATCH 06/31] Make GithubUrlReader use GithubCredentialsProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- .../src/reading/GithubUrlReader.ts | 34 +++++-- packages/integration/package.json | 3 +- ...ubApps.ts => GithubCredentialsProvider.ts} | 88 ++++++++++--------- packages/integration/src/github/config.ts | 9 +- packages/integration/src/github/index.ts | 1 + yarn.lock | 10 +++ 6 files changed, 94 insertions(+), 51 deletions(-) rename packages/integration/src/github/{githubApps.ts => GithubCredentialsProvider.ts} (74%) diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 5ca2a99692..255319dd20 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -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 { 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}`; diff --git a/packages/integration/package.json b/packages/integration/package.json index 52dc52a658..58c97ae4e5 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -34,12 +34,13 @@ "git-url-parse": "^11.4.3", "@octokit/rest": "^18.0.12", "@octokit/auth-app": "^2.10.5", - "moment": "^2.29.1" + "luxon": "^1.25.0" }, "devDependencies": { "@backstage/cli": "^0.4.5", "@backstage/test-utils": "^0.1.5", "@types/jest": "^26.0.7", + "@types/luxon": "^1.25.0", "msw": "^0.21.2" }, "files": [ diff --git a/packages/integration/src/github/githubApps.ts b/packages/integration/src/github/GithubCredentialsProvider.ts similarity index 74% rename from packages/integration/src/github/githubApps.ts rename to packages/integration/src/github/GithubCredentialsProvider.ts index 75f83e8662..490e062415 100644 --- a/packages/integration/src/github/githubApps.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -14,11 +14,11 @@ * 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 gitUrlParse from 'git-url-parse'; -import moment from 'moment'; +import { DateTime } from 'luxon'; import { InstallationAccessTokenAuthentication } from '@octokit/auth-app/dist-types/types'; type InstallationData = { @@ -30,26 +30,26 @@ type InstallationData = { class Cache { private readonly tokenCache = new Map< string, - { token: string; expiresAt: Date } + { token: string; expiresAt: DateTime } >(); - async getToken( + async getOrCreateToken( key: string, - fn: () => Promise<{ token: string; expiresAt: Date }>, + 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 fn(); + 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: Date) => - moment(date).isAfter(moment().subtract(50, 'minutes')); + private isNotExpired = (date: DateTime) => + date.diff(DateTime.local(), 'minutes').minutes > 50; } // GithubAppManager issues tokens for a speicifc GitHub App @@ -85,7 +85,7 @@ class GithubAppManager { // App is installed in the entire org if (repositorySelection === 'all') { - return this.cache.getToken(owner, async () => { + return this.cache.getOrCreateToken(owner, async () => { const auth = createAppAuth({ ...this.baseAuthConfig, installationId, @@ -95,19 +95,19 @@ class GithubAppManager { token, expiresAt, } = result as InstallationAccessTokenAuthentication; - return { token, expiresAt: new Date(expiresAt) }; + return { token, expiresAt: DateTime.fromISO(expiresAt) }; }); } // App is not installed org wide which requires a specific app token. - return this.cache.getToken(`${owner}/${repo}`, async () => { + return this.cache.getOrCreateToken(`${owner}/${repo}`, async () => { const result = await this.appClient.apps.createInstallationAccessToken({ installation_id: installationId, repositories: [repo], }); return { token: result.data.token, - expiresAt: new Date(result.data.expires_at), + expiresAt: DateTime.fromISO(result.data.expires_at), }; }); } @@ -150,18 +150,19 @@ class GithubAppManager { } } -// GithubIntegration corresponds to a Github installation which internally could hold several GitHub Apps. -class GithubIntegration { +// 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)) ?? []; } - async getCredentialsForAppInstallation( - owner: string, - repo: string, - ): Promise<{ accessToken: string }> { + async getAppToken(owner: string, repo: string): Promise { + if (this.apps.length === 0) { + return undefined; + } + const results = await Promise.all( this.apps.map(app => app.getInstallationCredentials(owner, repo).then( @@ -172,7 +173,7 @@ class GithubIntegration { ); const result = results.find(result => result.credentials); if (result) { - return result.credentials!; + return result.credentials!.accessToken; } const errors = results.map(r => r.error); @@ -180,39 +181,42 @@ class GithubIntegration { if (notNotFoundError) { throw notNotFoundError; } - const notFoundError = new Error( - `No app installation found for ${owner}/${repo}`, - ); - notFoundError.name = 'NotFoundError'; - throw notFoundError; + + return undefined; } } -export class GithubAppAuthProvider { - private readonly integrations: Map; - - constructor(configs: GitHubIntegrationConfig[]) { - this.integrations = new Map( - configs.map(config => [config.host, new GithubIntegration(config)]), +// 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, ); } - // getCredentials('github.com/backstage/somerepo') - async getCredentials(url: string): Promise<{ accessToken: string }> { - const parsed = gitUrlParse(url); + private constructor( + private readonly githubAppCredentialsMux: GithubAppCredentialsMux, + private readonly token?: string, + ) {} - const host = parsed.source; + async getCredentials(opts: { url: string }) { + const parsed = gitUrlParse(opts.url); const owner = parsed.owner; const repo = parsed.name; - const integration = await this.integrations.get(host); - const credentials = await integration?.getCredentialsForAppInstallation( - owner, - repo, - ); - if (!credentials) { - throw new Error(`No app installation found for ${owner}/${repo}`); + let token = await this.githubAppCredentialsMux.getAppToken(owner, repo); + if (!token) { + token = this.token; } - return credentials; + + return { + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + token, + }; } } diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index 527e287419..eced7584a4 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -86,6 +86,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( @@ -105,7 +112,7 @@ export function readGitHubIntegrationConfig( rawBaseUrl = GITHUB_RAW_BASE_URL; } - return { host, apiBaseUrl, rawBaseUrl, token }; + return { host, apiBaseUrl, rawBaseUrl, token, apps }; } /** diff --git a/packages/integration/src/github/index.ts b/packages/integration/src/github/index.ts index 5f97f6980a..6491e8dcc5 100644 --- a/packages/integration/src/github/index.ts +++ b/packages/integration/src/github/index.ts @@ -20,3 +20,4 @@ export { } from './config'; export type { GitHubIntegrationConfig } from './config'; export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core'; +export { GithubCredentialsProvider } from './GithubCredentialsProvider'; diff --git a/yarn.lock b/yarn.lock index ea4d11c6d3..6986104789 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6634,6 +6634,11 @@ 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" @@ -17662,6 +17667,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" From e64acdad765f113e42d21cb0c5665f2d90d66ce4 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Wed, 13 Jan 2021 16:19:01 +0100 Subject: [PATCH 07/31] Added tests and refactored provider Co-authored-by: blam --- .../github/GithubCredentialsProvider.test.ts | 265 ++++++++++++++++++ .../src/github/GithubCredentialsProvider.ts | 54 ++-- 2 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 packages/integration/src/github/GithubCredentialsProvider.test.ts diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts new file mode 100644 index 0000000000..34243009b1 --- /dev/null +++ b/packages/integration/src/github/GithubCredentialsProvider.test.ts @@ -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 applicatin 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( + 'Application must be installed for the entire organization', + ); + }); + + 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 app 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 }); + }); +}); diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index 490e062415..a1f21d0e69 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -19,7 +19,6 @@ import { GithubAppConfig, GitHubIntegrationConfig } from './config'; import { createAppAuth } from '@octokit/auth-app'; import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; import { DateTime } from 'luxon'; -import { InstallationAccessTokenAuthentication } from '@octokit/auth-app/dist-types/types'; type InstallationData = { installationId: number; @@ -72,7 +71,7 @@ class GithubAppManager { async getInstallationCredentials( owner: string, - repo: string, + repo?: string, ): Promise<{ accessToken: string }> { const { installationId, @@ -80,31 +79,27 @@ class GithubAppManager { repositorySelection, } = await this.getInstallationData(owner); if (suspended) { - throw new Error(`The app for ${owner}/${repo} is suspended`); + throw new Error( + `The app for ${[owner, repo].filter(Boolean).join('/')} is suspended`, + ); } - // App is installed in the entire org - if (repositorySelection === 'all') { - return this.cache.getOrCreateToken(owner, async () => { - const auth = createAppAuth({ - ...this.baseAuthConfig, - installationId, - }); - const result = await auth({ type: 'installation' }); - const { - token, - expiresAt, - } = result as InstallationAccessTokenAuthentication; - return { token, expiresAt: DateTime.fromISO(expiresAt) }; - }); + if (repositorySelection !== 'all' && !repo) { + throw new Error( + 'Application must be installed for the entire organization', + ); } - // App is not installed org wide which requires a specific app token. - return this.cache.getOrCreateToken(`${owner}/${repo}`, async () => { + 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, - repositories: [repo], + repositories, }); + return { token: result.data.token, expiresAt: DateTime.fromISO(result.data.expires_at), @@ -158,7 +153,7 @@ export class GithubAppCredentialsMux { this.apps = config.apps?.map(ac => new GithubAppManager(ac)) ?? []; } - async getAppToken(owner: string, repo: string): Promise { + async getAppToken(owner: string, repo?: string): Promise { if (this.apps.length === 0) { return undefined; } @@ -171,6 +166,7 @@ export class GithubAppCredentialsMux { ), ), ); + const result = results.find(result => result.credentials); if (result) { return result.credentials!.accessToken; @@ -186,6 +182,11 @@ export class GithubAppCredentialsMux { } } +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 { @@ -200,10 +201,15 @@ export class GithubCredentialsProvider { private readonly token?: string, ) {} - async getCredentials(opts: { url: string }) { + /** + * @returns GithubCredentials. + * @param opts + */ + async getCredentials(opts: { url: string }): Promise { const parsed = gitUrlParse(opts.url); - const owner = parsed.owner; - const repo = parsed.name; + + const owner = parsed.owner || parsed.name; + const repo = parsed.owner ? parsed.name : undefined; let token = await this.githubAppCredentialsMux.getAppToken(owner, repo); if (!token) { From d5189471b266836bb23565f38a06c0b6a0b4a524 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 09:04:14 +0100 Subject: [PATCH 08/31] Update packages/integration/src/github/GithubCredentialsProvider.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- .../integration/src/github/GithubCredentialsProvider.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts index 34243009b1..1bf5a11b9b 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.test.ts @@ -92,7 +92,7 @@ describe('GithubCredentialsProvider tests', () => { expect(token).toEqual(accessToken2); expect(headers).toEqual({ Authorization: 'Bearer secret_token' }); - // fallback to the configured token if no applicatin is matching + // fallback to the configured token if no application is matching await expect( github.getCredentials({ url: 'https://github.com/404/foobar', From e3ae3c29c5788c3130b6b670e8dd91c95bf0b520 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 09:08:19 +0100 Subject: [PATCH 09/31] Update packages/integration/src/github/config.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- packages/integration/src/github/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index eced7584a4..607033739f 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -62,7 +62,7 @@ export type GitHubIntegrationConfig = { /** * The GitHub Apps configuration to use for requests to this provider. * - * If no apps is specified, token or anonymous is used. + * If no apps are specified, token or anonymous is used. */ apps?: GithubAppConfig[]; }; From 779734303b22ad8266183f4942a11d42bbba04d9 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 10:20:44 +0100 Subject: [PATCH 10/31] Improve error for suspended app --- .../integration/src/github/GithubCredentialsProvider.test.ts | 2 +- packages/integration/src/github/GithubCredentialsProvider.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts index 1bf5a11b9b..a272714887 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.test.ts @@ -198,7 +198,7 @@ describe('GithubCredentialsProvider tests', () => { github.getCredentials({ url: 'https://github.com/backstage', }), - ).rejects.toThrow('The app for backstage is suspended'); + ).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 () => { diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index a1f21d0e69..c572dd3473 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -80,7 +80,9 @@ class GithubAppManager { } = await this.getInstallationData(owner); if (suspended) { throw new Error( - `The app for ${[owner, repo].filter(Boolean).join('/')} is suspended`, + `The GitHub application for ${[owner, repo] + .filter(Boolean) + .join('/')} is suspended`, ); } From 820430b2206655de1d40f4150030bc0b927770b6 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 10:22:09 +0100 Subject: [PATCH 11/31] Add docs for getCredentials --- .../integration/src/github/GithubCredentialsProvider.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index c572dd3473..ae1e73c3b8 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -204,8 +204,13 @@ export class GithubCredentialsProvider { ) {} /** - * @returns GithubCredentials. - * @param opts + * 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 { const parsed = gitUrlParse(opts.url); From ca875e665881dec30c2b192e5172e3bb6fc7afd8 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 10:44:52 +0100 Subject: [PATCH 12/31] Add docs for GithubAppConfig --- packages/integration/src/github/config.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index 607033739f..94ed00731e 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -67,13 +67,34 @@ export type GitHubIntegrationConfig = { 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; }; + /** * Reads a single GitHub integration config. * From 58cb92f91eb779376f3911e00274a76d0607a311 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 13:46:01 +0100 Subject: [PATCH 13/31] Improve installation error message --- .../integration/src/github/GithubCredentialsProvider.test.ts | 2 +- packages/integration/src/github/GithubCredentialsProvider.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts index a272714887..f708f75184 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.test.ts @@ -170,7 +170,7 @@ describe('GithubCredentialsProvider tests', () => { url: 'https://github.com/backstage', }), ).rejects.toThrow( - 'Application must be installed for the entire organization', + '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.', ); }); diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index ae1e73c3b8..04b38d6e27 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -88,7 +88,7 @@ class GithubAppManager { if (repositorySelection !== 'all' && !repo) { throw new Error( - 'Application must be installed for the entire organization', + `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.`, ); } From 4e623b7bf24bea5fcd1d35fa1fdb3f400bdb6de2 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 13:47:47 +0100 Subject: [PATCH 14/31] Add GitHub Apps documentation Co-authored-by: blam --- docs/plugins/github-apps.md | 78 +++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/plugins/github-apps.md diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md new file mode 100644 index 0000000000..0710f59158 --- /dev/null +++ b/docs/plugins/github-apps.md @@ -0,0 +1,78 @@ +# Using GithubApps for backend authentication + +Backstage can be configured to use GitHub Apps for backend authentication. This +come 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 be managed by Backstage. We currently don't check through + all the registered GitHub Apps to see which ones are installed for a + particular repository. We just 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 +- 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. + +```yaml +integrations: + github: + - host: github.com + apps: + - $include: example-backstage-app-credentials.yaml +``` From ed82e83a84b2b639b510a6af9daa26f94393374f Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 14:42:48 +0100 Subject: [PATCH 15/31] Add GitHubUrlReader test using CredentialsProvider Co-authored-by: blam --- .../src/reading/GithubUrlReader.test.ts | 116 ++++++++++++++++-- yarn.lock | 2 +- 2 files changed, 108 insertions(+), 10 deletions(-) diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index f842adcf90..1a0f27d28f 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -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( diff --git a/yarn.lock b/yarn.lock index 6986104789..e179b16698 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18434,7 +18434,7 @@ modify-values@^1.0.0: resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -moment@^2.25.3, moment@^2.26.0, moment@^2.27.0, moment@^2.29.1: +moment@^2.25.3, moment@^2.26.0, moment@^2.27.0: version "2.29.1" resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== From 8eaeb326aab74f1373ce533b370cbd80fba018dc Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 14:49:38 +0100 Subject: [PATCH 16/31] Fix spelling --- docs/plugins/github-apps.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 0710f59158..def9e32f4a 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -5,12 +5,12 @@ come 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. +the OAuth apps and their respective scopes. ## Caveats - It's not possible to have multiple backstage GitHub Apps installed in the same - Github organization be managed by Backstage. We currently don't check through + GitHub organization be managed by Backstage. We currently don't check through all the registered GitHub Apps to see which ones are installed for a particular repository. We just respect global Organization installs right now. - App permissions is not managed by Backstage. They're created with some simple @@ -29,7 +29,7 @@ to grant the app more permissions if needed. 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. +a GitHub app. You can read more about the `backstage-cli create-github-app` method [here](../cli/commands.md#create-github-app) @@ -66,7 +66,7 @@ privateKey: | 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 +[GitHub Enterprise](#gitHub-enterprise) instructions they can be included in the `app-config.yaml` under the integrations section. ```yaml From eb29c605f31389685e631ac17e83cba5839fb939 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 15:00:24 +0100 Subject: [PATCH 17/31] Document default permissions --- docs/plugins/github-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index def9e32f4a..6941d76ed4 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -16,7 +16,7 @@ the OAuth apps and their respective scopes. - 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 + 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. From 0b135e7e01f2e8cabfad9deb3b9e80c4ca265c8d Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 16:22:08 +0100 Subject: [PATCH 18/31] Add changeset for GitHub apps support --- .changeset/clever-timers-thank.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .changeset/clever-timers-thank.md diff --git a/.changeset/clever-timers-thank.md b/.changeset/clever-timers-thank.md new file mode 100644 index 0000000000..ffe39749de --- /dev/null +++ b/.changeset/clever-timers-thank.md @@ -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. From 83a9bb335feca14c4bbf266870ce8d462043686c Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 16:28:32 +0100 Subject: [PATCH 19/31] Fix spelling error --- .changeset/clever-timers-thank.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/clever-timers-thank.md b/.changeset/clever-timers-thank.md index ffe39749de..b3b8ad62ad 100644 --- a/.changeset/clever-timers-thank.md +++ b/.changeset/clever-timers-thank.md @@ -22,4 +22,4 @@ const { token, headers } = await credentialsProvider.getCredentials({ }); ``` -Updates `GithubUrlReader` to use the GithubCredentialsProvider. +Updates `GithubUrlReader` to use the `GithubCredentialsProvider`. From 63a706aed2ef27a117926c05e5fd81e8c39f18f0 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 16:31:54 +0100 Subject: [PATCH 20/31] Fix spelling error --- .changeset/clever-timers-thank.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/clever-timers-thank.md b/.changeset/clever-timers-thank.md index b3b8ad62ad..b5b75c0409 100644 --- a/.changeset/clever-timers-thank.md +++ b/.changeset/clever-timers-thank.md @@ -3,7 +3,7 @@ '@backstage/integration': patch --- -Add support for Github Apps authentication for backend plugins. +Add support for GitHub Apps authentication for backend plugins. `GithubCredentialsProvider` requests and caches GitHub credentials based on a repository or organization url. From 065498367d1310d36afbbbee2f0fec41662afb3d Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Thu, 14 Jan 2021 16:37:31 +0100 Subject: [PATCH 21/31] Find installation after try/catch --- .../src/github/GithubCredentialsProvider.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index 04b38d6e27..2dba69c392 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -112,25 +112,20 @@ class GithubAppManager { private async getInstallationData(owner: string): Promise { // List all installations using the last used etag. // Return cached InstallationData if error with status 304 is thrown. - let installation; try { this.installations = await this.appClient.apps.listInstallations({ headers: { 'If-None-Match': this.installations?.headers.etag, }, }); - - installation = this.installations.data.find( - inst => inst.account?.login === owner, - ); } catch (error) { if (error.status !== 304) { throw error; } - installation = this.installations?.data.find( - inst => inst.account?.login === owner, - ); } + const installation = this.installations?.data.find( + inst => inst.account?.login === owner, + ); if (installation) { return { installationId: installation.id, From 6037fa75c58bfe7ffc789ddddb1680c99b8344f6 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 15 Jan 2021 10:41:07 +0100 Subject: [PATCH 22/31] Set accept header and api base url --- .../src/github/GithubCredentialsProvider.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index 2dba69c392..cb928a4a6c 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -51,6 +51,15 @@ class Cache { 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 tokens for a speicifc GitHub App class GithubAppManager { private readonly appClient: Octokit; @@ -58,12 +67,14 @@ class GithubAppManager { private installations?: RestEndpointMethodTypes['apps']['listInstallations']['response']; private readonly cache = new Cache(); - constructor(config: GithubAppConfig) { + 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, }); @@ -85,7 +96,6 @@ class GithubAppManager { .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.`, @@ -99,9 +109,9 @@ class GithubAppManager { 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), @@ -116,6 +126,7 @@ class GithubAppManager { this.installations = await this.appClient.apps.listInstallations({ headers: { 'If-None-Match': this.installations?.headers.etag, + Accept: HEADERS.Accept, }, }); } catch (error) { @@ -133,7 +144,6 @@ class GithubAppManager { repositorySelection: installation.repository_selection, }; } - const notFoundError = new Error( `No app installation found for ${owner} in ${this.baseAuthConfig.appId}`, ); @@ -147,7 +157,8 @@ export class GithubAppCredentialsMux { private readonly apps: GithubAppManager[]; constructor(config: GitHubIntegrationConfig) { - this.apps = config.apps?.map(ac => new GithubAppManager(ac)) ?? []; + this.apps = + config.apps?.map(ac => new GithubAppManager(ac, config.apiBaseUrl)) ?? []; } async getAppToken(owner: string, repo?: string): Promise { From fc15529cf4855fabd949fa340eaaae356f02688f Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 15 Jan 2021 15:40:46 +0100 Subject: [PATCH 23/31] Update docs/plugins/github-apps.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/plugins/github-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 6941d76ed4..6cf6ac5d2a 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -1,7 +1,7 @@ # Using GithubApps for backend authentication Backstage can be configured to use GitHub Apps for backend authentication. This -come with advantages such as higher rate limits and that Backstage can act as an +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 From 57b542c73691105f435c771bd7a10fd614f1ff4c Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 15 Jan 2021 15:41:03 +0100 Subject: [PATCH 24/31] Update docs/plugins/github-apps.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/plugins/github-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 6cf6ac5d2a..7738cae624 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -9,7 +9,7 @@ the OAuth apps and their respective scopes. ## Caveats -- It's not possible to have multiple backstage GitHub Apps installed in the same +- It's not possible to have multiple Backstage GitHub Apps installed in the same GitHub organization be managed by Backstage. We currently don't check through all the registered GitHub Apps to see which ones are installed for a particular repository. We just respect global Organization installs right now. From 084e2450d181d956f6f9f7f4eadb5934ecbe710c Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 15 Jan 2021 15:41:23 +0100 Subject: [PATCH 25/31] Update docs/plugins/github-apps.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/plugins/github-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 7738cae624..a94ad212f9 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -12,7 +12,7 @@ the OAuth apps and their respective scopes. - It's not possible to have multiple Backstage GitHub Apps installed in the same GitHub organization be managed by Backstage. We currently don't check through all the registered GitHub Apps to see which ones are installed for a - particular repository. We just respect global Organization installs right now. + 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 From b8f87997867b2844401e0dbc257d3a307dc9fe31 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 15 Jan 2021 15:41:50 +0100 Subject: [PATCH 26/31] Update docs/plugins/github-apps.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/plugins/github-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index a94ad212f9..a9758afb57 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -67,7 +67,7 @@ privateKey: | 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. +`app-config.yaml` under the `integrations` section. ```yaml integrations: From fffe91dba5d904f39926e5c033af59447b3a7303 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 15 Jan 2021 15:42:06 +0100 Subject: [PATCH 27/31] Update docs/plugins/github-apps.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/plugins/github-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index a9758afb57..675b3c91a7 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -66,7 +66,7 @@ privateKey: | 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 +[GitHub Enterprise](#gitHub-enterprise) instructions, they can be included in the `app-config.yaml` under the `integrations` section. ```yaml From 27745f651af014fc7109e5187ef32aa142bb2cf1 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 15 Jan 2021 15:42:13 +0100 Subject: [PATCH 28/31] Update docs/plugins/github-apps.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/plugins/github-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 675b3c91a7..0503b7c9f0 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -64,7 +64,7 @@ privateKey: | ### Including in Integrations Config -Once the credentials are stored in a `yaml` file generated by +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. From b5ed171a1ac218c09b690aba2f690dbbef8b3595 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 15 Jan 2021 15:42:25 +0100 Subject: [PATCH 29/31] Update docs/plugins/github-apps.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/plugins/github-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 0503b7c9f0..b07eaeacc2 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -10,7 +10,7 @@ the OAuth apps and their respective scopes. ## Caveats - It's not possible to have multiple Backstage GitHub Apps installed in the same - GitHub organization be managed by Backstage. We currently don't check through + 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 From 50b933d9ac66b41363c0ede684be1caed9572e9e Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 15 Jan 2021 15:42:39 +0100 Subject: [PATCH 30/31] Update docs/plugins/github-apps.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw --- docs/plugins/github-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index b07eaeacc2..1bb2af8524 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -1,4 +1,4 @@ -# Using GithubApps for backend authentication +# 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 From 48f3a5dcae4eeddf3916132a75caf69361aadc52 Mon Sep 17 00:00:00 2001 From: Johan Haals Date: Fri, 15 Jan 2021 16:13:19 +0100 Subject: [PATCH 31/31] Update docs --- docs/plugins/github-apps.md | 20 +++++++++++-------- .../src/github/GithubCredentialsProvider.ts | 4 +++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 1bb2af8524..d3b0e36cd9 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -1,8 +1,8 @@ # 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. +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. @@ -10,8 +10,8 @@ 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 + 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 @@ -64,10 +64,14 @@ privateKey: | ### 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. +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: diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts index cb928a4a6c..843ef629ab 100644 --- a/packages/integration/src/github/GithubCredentialsProvider.ts +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -60,7 +60,9 @@ const HEADERS = { Accept: 'application/vnd.github.machine-man-preview+json', }; -// GithubAppManager issues tokens for a speicifc GitHub App +/** + * GithubAppManager issues and caches tokens for a specific GitHub App. + */ class GithubAppManager { private readonly appClient: Octokit; private readonly baseAuthConfig: { appId: number; privateKey: string };