diff --git a/.changeset/clever-timers-thank.md b/.changeset/clever-timers-thank.md new file mode 100644 index 0000000000..b5b75c0409 --- /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`. diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md new file mode 100644 index 0000000000..d3b0e36cd9 --- /dev/null +++ b/docs/plugins/github-apps.md @@ -0,0 +1,82 @@ +# Using GitHub Apps for Backend Authentication + +Backstage can be configured to use GitHub Apps for backend authentication. This +comes with advantages such as higher rate limits and that Backstage can act as +an application instead of a user or bot account. + +It also provides a much clearer and better authorization model as a opposed to +the OAuth apps and their respective scopes. + +## Caveats + +- It's not possible to have multiple Backstage GitHub Apps installed in the same + GitHub organization, to be handled by Backstage. We currently don't check + through all the registered GitHub Apps to see which ones are installed for a + particular repository. We only respect global Organization installs right now. +- App permissions is not managed by Backstage. They're created with some simple + default permissions which you are free to change as you need, but you will + need to update them in the GitHub web console, not in Backstage right now. The + permissions that are defaulted are `metadata:read` and `contents:read`. +- The created GitHub App is private by default, this is most likely what you + want for github.com but it's recommended to make your application public for + GitHub Enterprise in order to share application across your GHE organizations. + +A GitHub app created with `backstage-cli create-github-app` will have read +access by default. You have to manually update the GitHub App settings in GitHub +to grant the app more permissions if needed. + +### Using the CLI (public GitHub only) + +You can use the `backstage-cli` to create GitHub App' using a manifest file that +we provide. This gives us a way to automate some of the work required to create +a GitHub app. + +You can read more about the `backstage-cli create-github-app` method +[here](../cli/commands.md#create-github-app) + +Once you've gone through the CLI command, it should produce a `yaml` file in the +root of the project which you can then use as an `include` in your +`app-config.yaml`. You can go ahead and skip to +[here](#including-in-integrations-config) if you've got to this part. + +### GitHub Enterprise + +You have to create the GitHub Application manually using these +[instructions](https://docs.github.com/en/free-pro-team@latest/developers/apps/creating-a-github-app) +as GitHub Enterprise does not support creation of apps from manifests. + +Once the application is created you have to generate a private key for the +application it in a `yaml` file. + +The yaml file must include the following information. Please note that the +indentation for the `privateKey` is required. + +```yaml +appId: 1 +clientId: client id +clientSecret: client secret +webhookSecret: webhook secret +privateKey: | + -----BEGIN RSA PRIVATE KEY----- + ...Key content... + -----END RSA PRIVATE KEY----- +``` + +### Including in Integrations Config + +Once the credentials are stored in a yaml file generated by `create-github-app` +or manually by following the [GitHub Enterprise](#gitHub-enterprise) +instructions, they can be included in the `app-config.yaml` under the +`integrations` section. + +Please note that the credentials file is highly sensitive and should NOT be +checked into any kind of version control. Instead use your preferred secure +method of distributing secrets. + +```yaml +integrations: + github: + - host: github.com + apps: + - $include: example-backstage-app-credentials.yaml +``` 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/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/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 eff9e541ce..8a8804c8e5 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -31,12 +31,16 @@ "dependencies": { "@backstage/config": "^0.1.2", "cross-fetch": "^3.0.6", - "git-url-parse": "^11.4.3" + "git-url-parse": "^11.4.3", + "@octokit/rest": "^18.0.12", + "@octokit/auth-app": "^2.10.5", + "luxon": "^1.25.0" }, "devDependencies": { "@backstage/cli": "^0.4.6", "@backstage/test-utils": "^0.1.5", "@types/jest": "^26.0.7", + "@types/luxon": "^1.25.0", "msw": "^0.21.2" }, "files": [ diff --git a/packages/integration/src/github/GithubCredentialsProvider.test.ts b/packages/integration/src/github/GithubCredentialsProvider.test.ts new file mode 100644 index 0000000000..f708f75184 --- /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 application is matching + await expect( + github.getCredentials({ + url: 'https://github.com/404/foobar', + }), + ).resolves.toEqual({ + headers: { + Authorization: 'Bearer hardcoded_token', + }, + token: 'hardcoded_token', + }); + }); + + it('creates tokens for an organization', async () => { + octokit.apps.listInstallations.mockResolvedValueOnce({ + headers: { + etag: '123', + }, + data: [ + { + id: 1, + repository_selection: 'all', + account: { + login: 'backstage', + }, + }, + ], + } as RestEndpointMethodTypes['apps']['listInstallations']['response']); + octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); + + octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({ + data: { + expires_at: DateTime.local().plus({ hour: 1 }).toString(), + token: 'secret_token', + }, + } as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']); + + const { token, headers } = await github.getCredentials({ + url: 'https://github.com/backstage', + }); + const { token: accessToken2 } = await github.getCredentials({ + url: 'https://github.com/backstage', + }); + + expect(headers).toEqual({ Authorization: 'Bearer secret_token' }); + expect(token).toEqual('secret_token'); + expect(token).toEqual(accessToken2); + }); + + it('should fail to issue tokens for an organization when the app is installed for a single repo', async () => { + octokit.apps.listInstallations.mockResolvedValueOnce({ + headers: { + etag: '123', + }, + data: [ + { + id: 1, + repository_selection: 'selected', + account: { + login: 'backstage', + }, + }, + ], + } as RestEndpointMethodTypes['apps']['listInstallations']['response']); + octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); + + octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({ + data: { + expires_at: DateTime.local().plus({ hour: 1 }).toString(), + token: 'secret_token', + }, + } as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']); + + await expect( + github.getCredentials({ + url: 'https://github.com/backstage', + }), + ).rejects.toThrow( + 'The Backstage GitHub application used in the backstage organization must be installed for the entire organization to be able to issue credentials without a specified repository.', + ); + }); + + it('should throw if the app is suspended', async () => { + octokit.apps.listInstallations.mockResolvedValueOnce({ + headers: { + etag: '123', + }, + data: [ + { + id: 1, + suspended_by: { + login: 'admin', + }, + repository_selection: 'all', + account: { + login: 'backstage', + }, + }, + ], + } as RestEndpointMethodTypes['apps']['listInstallations']['response']); + octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); + + await expect( + github.getCredentials({ + url: 'https://github.com/backstage', + }), + ).rejects.toThrow('The GitHub application for backstage is suspended'); + }); + + it('should return the default token when the call to github return a status that is not recognized', async () => { + octokit.apps.listInstallations.mockRejectedValue({ + status: 404, + message: 'NotFound', + }); + + await expect( + github.getCredentials({ + url: 'https://github.com/backstage', + }), + ).rejects.toEqual({ status: 404, message: 'NotFound' }); + }); + + it('should return the default token if no app is configured', async () => { + const github = GithubCredentialsProvider.create({ + host: 'github.com', + apps: [], + token: 'fallback_token', + }); + + await expect( + github.getCredentials({ + url: 'https://github.com/404/foobar', + }), + ).resolves.toEqual(expect.objectContaining({ token: 'fallback_token' })); + }); + + it('should return the configured token if listing installations throws', async () => { + const github = GithubCredentialsProvider.create({ + host: 'github.com', + apps: [ + { + appId: 1, + privateKey: 'privateKey', + webhookSecret: '123', + clientId: 'CLIENT_ID', + clientSecret: 'CLIENT_SECRET', + }, + ], + token: 'hardcoded_token', + }); + octokit.apps.listInstallations.mockRejectedValue({ status: 304 }); + + await expect( + github.getCredentials({ + url: 'https://github.com/backstage', + }), + ).resolves.toEqual(expect.objectContaining({ token: 'hardcoded_token' })); + }); + + it('should return undefined if no token or apps are configured', async () => { + const github = GithubCredentialsProvider.create({ + host: 'github.com', + }); + + await expect( + github.getCredentials({ + url: 'https://github.com/backstage', + }), + ).resolves.toEqual({ headers: undefined, token: undefined }); + }); +}); diff --git a/packages/integration/src/github/GithubCredentialsProvider.ts b/packages/integration/src/github/GithubCredentialsProvider.ts new file mode 100644 index 0000000000..843ef629ab --- /dev/null +++ b/packages/integration/src/github/GithubCredentialsProvider.ts @@ -0,0 +1,243 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import gitUrlParse from 'git-url-parse'; +import { GithubAppConfig, GitHubIntegrationConfig } from './config'; +import { createAppAuth } from '@octokit/auth-app'; +import { Octokit, RestEndpointMethodTypes } from '@octokit/rest'; +import { DateTime } from 'luxon'; + +type InstallationData = { + installationId: number; + suspended: boolean; + repositorySelection: 'selected' | 'all'; +}; + +class Cache { + private readonly tokenCache = new Map< + string, + { token: string; expiresAt: DateTime } + >(); + + async getOrCreateToken( + key: string, + supplier: () => Promise<{ token: string; expiresAt: DateTime }>, + ): Promise<{ accessToken: string }> { + const item = this.tokenCache.get(key); + if (item && this.isNotExpired(item.expiresAt)) { + return { accessToken: item.token }; + } + + const result = await supplier(); + this.tokenCache.set(key, result); + return { accessToken: result.token }; + } + + // consider timestamps older than 50 minutes to be expired. + private isNotExpired = (date: DateTime) => + date.diff(DateTime.local(), 'minutes').minutes > 50; +} + +/** + * This accept header is required when calling App APIs in GitHub Enterprise. + * It has no effect on calls to github.com and can probably be removed entierly + * once GitHub Apps is out of preview. + */ +const HEADERS = { + Accept: 'application/vnd.github.machine-man-preview+json', +}; + +/** + * GithubAppManager issues and caches tokens for a specific GitHub App. + */ +class GithubAppManager { + private readonly appClient: Octokit; + private readonly baseAuthConfig: { appId: number; privateKey: string }; + private installations?: RestEndpointMethodTypes['apps']['listInstallations']['response']; + private readonly cache = new Cache(); + + constructor(config: GithubAppConfig, baseUrl?: string) { + this.baseAuthConfig = { + appId: config.appId, + privateKey: config.privateKey, + }; + this.appClient = new Octokit({ + baseUrl, + headers: HEADERS, + authStrategy: createAppAuth, + auth: this.baseAuthConfig, + }); + } + + async getInstallationCredentials( + owner: string, + repo?: string, + ): Promise<{ accessToken: string }> { + const { + installationId, + suspended, + repositorySelection, + } = await this.getInstallationData(owner); + if (suspended) { + throw new Error( + `The GitHub application for ${[owner, repo] + .filter(Boolean) + .join('/')} is suspended`, + ); + } + if (repositorySelection !== 'all' && !repo) { + throw new Error( + `The Backstage GitHub application used in the ${owner} organization must be installed for the entire organization to be able to issue credentials without a specified repository.`, + ); + } + + const cacheKey = !repo ? owner : `${owner}/${repo}`; + const repositories = repositorySelection !== 'all' ? [repo!] : undefined; + + // Go and grab an access token for the app scoped to a repository if provided, if not use the organisation installation. + return this.cache.getOrCreateToken(cacheKey, async () => { + const result = await this.appClient.apps.createInstallationAccessToken({ + installation_id: installationId, + headers: HEADERS, + repositories, + }); + return { + token: result.data.token, + expiresAt: DateTime.fromISO(result.data.expires_at), + }; + }); + } + + private async getInstallationData(owner: string): Promise { + // List all installations using the last used etag. + // Return cached InstallationData if error with status 304 is thrown. + try { + this.installations = await this.appClient.apps.listInstallations({ + headers: { + 'If-None-Match': this.installations?.headers.etag, + Accept: HEADERS.Accept, + }, + }); + } catch (error) { + if (error.status !== 304) { + throw error; + } + } + const installation = this.installations?.data.find( + inst => inst.account?.login === owner, + ); + if (installation) { + return { + installationId: installation.id, + suspended: Boolean(installation.suspended_by), + repositorySelection: installation.repository_selection, + }; + } + const notFoundError = new Error( + `No app installation found for ${owner} in ${this.baseAuthConfig.appId}`, + ); + notFoundError.name = 'NotFoundError'; + throw notFoundError; + } +} + +// GithubAppCredentialsMux corresponds to a Github installation which internally could hold several GitHub Apps. +export class GithubAppCredentialsMux { + private readonly apps: GithubAppManager[]; + + constructor(config: GitHubIntegrationConfig) { + this.apps = + config.apps?.map(ac => new GithubAppManager(ac, config.apiBaseUrl)) ?? []; + } + + async getAppToken(owner: string, repo?: string): Promise { + if (this.apps.length === 0) { + return undefined; + } + + const results = await Promise.all( + this.apps.map(app => + app.getInstallationCredentials(owner, repo).then( + credentials => ({ credentials, error: undefined }), + error => ({ credentials: undefined, error }), + ), + ), + ); + + const result = results.find(result => result.credentials); + if (result) { + return result.credentials!.accessToken; + } + + const errors = results.map(r => r.error); + const notNotFoundError = errors.find(err => err.name !== 'NotFoundError'); + if (notNotFoundError) { + throw notNotFoundError; + } + + return undefined; + } +} + +export type GithubCredentials = { + headers?: { [name: string]: string }; + token?: string; +}; + +// TODO: Possibly move this to a backend only package so that it's not used in the frontend by mistake +export class GithubCredentialsProvider { + static create(config: GitHubIntegrationConfig): GithubCredentialsProvider { + return new GithubCredentialsProvider( + new GithubAppCredentialsMux(config), + config.token, + ); + } + + private constructor( + private readonly githubAppCredentialsMux: GithubAppCredentialsMux, + private readonly token?: string, + ) {} + + /** + * Returns GithubCredentials for requested url. + * Consecutive calls to this method with the same url will return cached credentials. + * The shortest lifetime for a token returned is 10 minutes. + * @param opts containing the organization or repository url + * @returns {Promise} of @type {GithubCredentials}. + * @example + * const { token, headers } = await getCredentials({url: 'github.com/backstage/foobar'}) + */ + async getCredentials(opts: { url: string }): Promise { + const parsed = gitUrlParse(opts.url); + + const owner = parsed.owner || parsed.name; + const repo = parsed.owner ? parsed.name : undefined; + + let token = await this.githubAppCredentialsMux.getAppToken(owner, repo); + if (!token) { + token = this.token; + } + + return { + headers: token + ? { + Authorization: `Bearer ${token}`, + } + : undefined, + token, + }; + } +} diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index 22e8ad57d8..94ed00731e 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -58,6 +58,41 @@ export type GitHubIntegrationConfig = { * If no token is specified, anonymous access is used. */ token?: string; + + /** + * The GitHub Apps configuration to use for requests to this provider. + * + * If no apps are specified, token or anonymous is used. + */ + apps?: GithubAppConfig[]; +}; + +/** + * The configuration parameters for authenticating a GitHub Application. + * A Github Apps configuration can be generated using the `backstage-cli create-github-app` command. + */ +export type GithubAppConfig = { + /** + * Unique app identifier, found at https://github.com/organizations/$org/settings/apps/$AppName + */ + appId: number; + /** + * The private key is used by the GitHub App integration to authenticate the app. + * A private key can be generated from the app at https://github.com/organizations/$org/settings/apps/$AppName + */ + privateKey: string; + /** + * Webhook secret can be configured at https://github.com/organizations/$org/settings/apps/$AppName + */ + webhookSecret: string; + /** + * Found at https://github.com/organizations/$org/settings/apps/$AppName + */ + clientId: string; + /** + * Client secrets can be generated at https://github.com/organizations/$org/settings/apps/$AppName + */ + clientSecret: string; }; /** @@ -72,6 +107,13 @@ export function readGitHubIntegrationConfig( let apiBaseUrl = config.getOptionalString('apiBaseUrl'); let rawBaseUrl = config.getOptionalString('rawBaseUrl'); const token = config.getOptionalString('token'); + const apps = config.getOptionalConfigArray('apps')?.map(c => ({ + appId: c.getNumber('appId'), + clientId: c.getString('clientId'), + clientSecret: c.getString('clientSecret'), + webhookSecret: c.getString('webhookSecret'), + privateKey: c.getString('privateKey'), + })); if (!isValidHost(host)) { throw new Error( @@ -91,7 +133,7 @@ export function readGitHubIntegrationConfig( rawBaseUrl = GITHUB_RAW_BASE_URL; } - return { host, apiBaseUrl, rawBaseUrl, token }; + return { host, apiBaseUrl, rawBaseUrl, token, apps }; } /** 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 7fdc2c3a62..fa2f5aca5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4660,32 +4660,27 @@ dependencies: mkdirp "^1.0.4" -"@octokit/auth-token@^2.4.0": - version "2.4.0" - resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.0.tgz#b64178975218b99e4dfe948253f0673cbbb59d9f" - integrity sha512-eoOVMjILna7FVQf96iWc3+ZtE/ZT6y8ob8ZzcqKY1ibSQCnu4O/B7pJvzMx5cyZ/RjAff6DAdEb0O0Cjcxidkg== +"@octokit/auth-app@^2.10.5": + version "2.10.5" + resolved "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-2.10.5.tgz#85d69cb96818f5da34bf0b81bb637d3675ad4e9a" + integrity sha512-6yXyjtcBWpuPYSdZN8z8IIjGSqkPmiJzdmCdod8at41ANB1FtaKbUIDL5+IkG+svv68NIYs+XORbhBRFXYB3bw== dependencies: - "@octokit/types" "^2.0.0" + "@octokit/request" "^5.4.11" + "@octokit/request-error" "^2.0.0" + "@octokit/types" "^6.0.3" + "@types/lru-cache" "^5.1.0" + deprecation "^2.3.1" + lru-cache "^6.0.0" + universal-github-app-jwt "^1.0.1" + universal-user-agent "^6.0.0" -"@octokit/auth-token@^2.4.4": +"@octokit/auth-token@^2.4.0", "@octokit/auth-token@^2.4.4": version "2.4.4" resolved "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.4.tgz#ee31c69b01d0378c12fd3ffe406030f3d94d3b56" integrity sha512-LNfGu3Ro9uFAYh10MUZVaT7X2CnNm2C8IDQmabx+3DygYIQjs9FwzFAHN/0t6mu5HEPhxcb1XOuxdpY82vCg2Q== dependencies: "@octokit/types" "^6.0.0" -"@octokit/core@^3.0.0": - version "3.1.0" - resolved "https://registry.npmjs.org/@octokit/core/-/core-3.1.0.tgz#9c3c9b23f7504668cfa057f143ccbf0c645a0ac9" - integrity sha512-yPyQSmxIXLieEIRikk2w8AEtWkFdfG/LXcw1KvEtK3iP0ENZLW/WYQmdzOKqfSaLhooz4CJ9D+WY79C8ZliACw== - dependencies: - "@octokit/auth-token" "^2.4.0" - "@octokit/graphql" "^4.3.1" - "@octokit/request" "^5.4.0" - "@octokit/types" "^5.0.0" - before-after-hook "^2.1.0" - universal-user-agent "^5.0.0" - "@octokit/core@^3.2.3": version "3.2.4" resolved "https://registry.npmjs.org/@octokit/core/-/core-3.2.4.tgz#5791256057a962eca972e31818f02454897fd106" @@ -4707,15 +4702,6 @@ is-plain-object "^3.0.0" universal-user-agent "^5.0.0" -"@octokit/graphql@^4.3.1": - version "4.5.1" - resolved "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.5.1.tgz#162aed1490320b88ce34775b3f6b8de945529fa9" - integrity sha512-qgMsROG9K2KxDs12CO3bySJaYoUu2aic90qpFrv7A8sEBzZ7UFGvdgPKiLw5gOPYEYbS0Xf8Tvf84tJutHPulQ== - dependencies: - "@octokit/request" "^5.3.0" - "@octokit/types" "^5.0.0" - universal-user-agent "^5.0.0" - "@octokit/graphql@^4.5.8": version "4.5.8" resolved "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.5.8.tgz#d42373633c3015d0eafce64a8ce196be167fdd9b" @@ -4742,13 +4728,6 @@ dependencies: "@octokit/types" "^2.0.1" -"@octokit/plugin-paginate-rest@^2.2.0": - version "2.2.3" - resolved "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.2.3.tgz#a6ad4377e7e7832fb4bdd9d421e600cb7640ac27" - integrity sha512-eKTs91wXnJH8Yicwa30jz6DF50kAh7vkcqCQ9D7/tvBAP5KKkg6I2nNof8Mp/65G0Arjsb4QcOJcIEQY+rK1Rg== - dependencies: - "@octokit/types" "^5.0.0" - "@octokit/plugin-paginate-rest@^2.6.2": version "2.7.0" resolved "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.7.0.tgz#6bb7b043c246e0654119a6ec4e72a172c9e2c7f3" @@ -4756,12 +4735,7 @@ dependencies: "@octokit/types" "^6.0.1" -"@octokit/plugin-request-log@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.0.tgz#eef87a431300f6148c39a7f75f8cfeb218b2547e" - integrity sha512-ywoxP68aOT3zHCLgWZgwUJatiENeHE7xJzYjfz8WI0goynp96wETBF+d95b8g/uL4QmS6owPVlaxiz3wyMAzcw== - -"@octokit/plugin-request-log@^1.0.2": +"@octokit/plugin-request-log@^1.0.0", "@octokit/plugin-request-log@^1.0.2": version "1.0.2" resolved "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.2.tgz#394d59ec734cd2f122431fbaf05099861ece3c44" integrity sha512-oTJSNAmBqyDR41uSMunLQKMX0jmEXbwD1fpz8FG27lScV3RhtGfBa1/BBLym+PxcC16IBlF7KH9vP1BUYxA+Eg== @@ -4774,14 +4748,6 @@ "@octokit/types" "^2.0.1" deprecation "^2.3.1" -"@octokit/plugin-rest-endpoint-methods@4.1.4": - version "4.1.4" - resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.1.4.tgz#ca60736d761b304fec02a2608caaec2d822e9835" - integrity sha512-Y2tVpSa7HjV3DGIQrQOJcReJ2JtcN9FaGr9jDa332Flro923/h3/Iu9e7Y4GilnzfLclHEh5iCQoCkHm7tWOcg== - dependencies: - "@octokit/types" "^5.4.1" - deprecation "^2.3.1" - "@octokit/plugin-rest-endpoint-methods@4.4.1": version "4.4.1" resolved "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.4.1.tgz#105cf93255432155de078c9efc33bd4e14d1cd63" @@ -4808,21 +4774,7 @@ deprecation "^2.0.0" once "^1.4.0" -"@octokit/request@^5.2.0", "@octokit/request@^5.3.0", "@octokit/request@^5.4.0": - version "5.4.5" - resolved "https://registry.npmjs.org/@octokit/request/-/request-5.4.5.tgz#8df65bd812047521f7e9db6ff118c06ba84ac10b" - integrity sha512-atAs5GAGbZedvJXXdjtKljin+e2SltEs48B3naJjqWupYl2IUBbB/CJisyjbNHcKpHzb3E+OYEZ46G8eakXgQg== - dependencies: - "@octokit/endpoint" "^6.0.1" - "@octokit/request-error" "^2.0.0" - "@octokit/types" "^5.0.0" - deprecation "^2.0.0" - is-plain-object "^3.0.0" - node-fetch "^2.3.0" - once "^1.4.0" - universal-user-agent "^5.0.0" - -"@octokit/request@^5.4.12": +"@octokit/request@^5.2.0", "@octokit/request@^5.3.0", "@octokit/request@^5.4.11", "@octokit/request@^5.4.12": version "5.4.12" resolved "https://registry.npmjs.org/@octokit/request/-/request-5.4.12.tgz#b04826fa934670c56b135a81447be2c1723a2ffc" integrity sha512-MvWYdxengUWTGFpfpefBBpVmmEYfkwMoxonIB3sUGp5rhdgwjXL1ejo6JbgzG/QD9B/NYt/9cJX1pxXeSIUCkg== @@ -4858,17 +4810,7 @@ once "^1.4.0" universal-user-agent "^4.0.0" -"@octokit/rest@^18.0.0": - version "18.0.5" - resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.5.tgz#1f1498dcdc2d85d0f86b8168e4ff5779842b2742" - integrity sha512-SPKI24tQXrr1XsnaIjv2x0rl4M5eF1+hj8+vMe3d/exZ7NnL5sTe1BuFyCyJyrc+j1HkXankvgGN9zT0rwBwtg== - dependencies: - "@octokit/core" "^3.0.0" - "@octokit/plugin-paginate-rest" "^2.2.0" - "@octokit/plugin-request-log" "^1.0.0" - "@octokit/plugin-rest-endpoint-methods" "4.1.4" - -"@octokit/rest@^18.0.12": +"@octokit/rest@^18.0.0", "@octokit/rest@^18.0.12": version "18.0.12" resolved "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.12.tgz#278bd41358c56d87c201e787e8adc0cac132503a" integrity sha512-hNRCZfKPpeaIjOVuNJzkEL6zacfZlBPV8vw8ReNeyUkVvbuCvvrrx8K8Gw2eyHHsmd4dPlAxIXIZ9oHhJfkJpw== @@ -4886,16 +4828,9 @@ "@types/node" ">= 8" "@octokit/types@^5.0.0", "@octokit/types@^5.0.1": - version "5.0.1" - resolved "https://registry.npmjs.org/@octokit/types/-/types-5.0.1.tgz#5459e9a5e9df8565dcc62c17a34491904d71971e" - integrity sha512-GorvORVwp244fGKEt3cgt/P+M0MGy4xEDbckw+K5ojEezxyMDgCaYPKVct+/eWQfZXOT7uq0xRpmrl/+hliabA== - dependencies: - "@types/node" ">= 8" - -"@octokit/types@^5.4.1": - version "5.4.1" - resolved "https://registry.npmjs.org/@octokit/types/-/types-5.4.1.tgz#d5d5f2b70ffc0e3f89467c3db749fa87fc3b7031" - integrity sha512-OlMlSySBJoJ6uozkr/i03nO5dlYQyE05vmQNZhAh9MyO4DPBP88QlwsDVLmVjIMFssvIZB6WO0ctIGMRG+xsJQ== + version "5.5.0" + resolved "https://registry.npmjs.org/@octokit/types/-/types-5.5.0.tgz#e5f06e8db21246ca102aa28444cdb13ae17a139b" + integrity sha512-UZ1pErDue6bZNjYOotCNveTXArOMZQFG6hKJfOnGnulVCMcVVi7YIIuuR4WfBhjo7zgpmzn/BkPDnUXtNx+PcQ== dependencies: "@types/node" ">= 8" @@ -6623,7 +6558,7 @@ resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= -"@types/jsonwebtoken@^8.5.0": +"@types/jsonwebtoken@^8.3.3", "@types/jsonwebtoken@^8.5.0": version "8.5.0" resolved "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz#2531d5e300803aa63279b232c014acf780c981c5" integrity sha512-9bVao7LvyorRGZCw0VmH/dr7Og+NdjYSsKAxB43OQoComFbBgsEpoR9JW6+qSq/ogwVBg8GI2MfAlk4SYI4OLg== @@ -6686,6 +6621,16 @@ resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== +"@types/lru-cache@^5.1.0": + version "5.1.0" + resolved "https://registry.npmjs.org/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03" + integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w== + +"@types/luxon@^1.25.0": + version "1.25.0" + resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.25.0.tgz#3d6fe591fac874f48dd225cb5660b2b785a21a05" + integrity sha512-iIJp2CP6C32gVqI08HIYnzqj55tlLnodIBMCcMf28q9ckqMfMzocCmIzd9JWI/ALLPMUiTkCu1JGv3FFtu6t3g== + "@types/markdown-to-jsx@^6.11.0": version "6.11.2" resolved "https://registry.npmjs.org/@types/markdown-to-jsx/-/markdown-to-jsx-6.11.2.tgz#05d1aaffbf15be7be12c70535fa4fed65cc7c64f" @@ -17714,6 +17659,11 @@ lru-queue@0.1: dependencies: es5-ext "~0.10.2" +luxon@^1.25.0: + version "1.25.0" + resolved "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz#d86219e90bc0102c0eb299d65b2f5e95efe1fe72" + integrity sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ== + macos-release@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f" @@ -25147,6 +25097,14 @@ unist-util-visit@^2.0.0: unist-util-is "^4.0.0" unist-util-visit-parents "^3.0.0" +universal-github-app-jwt@^1.0.1: + version "1.1.0" + resolved "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-1.1.0.tgz#0abaa876101cdf1d3e4c546be2768841c0c1b514" + integrity sha512-3b+ocAjjz4JTyqaOT+NNBd5BtTuvJTxWElIoeHSVelUV9J3Jp7avmQTdLKCaoqi/5Ox2o/q+VK19TJ233rVXVQ== + dependencies: + "@types/jsonwebtoken" "^8.3.3" + jsonwebtoken "^8.5.1" + universal-user-agent@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-4.0.1.tgz#fd8d6cb773a679a709e967ef8288a31fcc03e557"