diff --git a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts index 3d58ffbb61..b48103eea1 100644 --- a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts @@ -57,6 +57,7 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => { token: 'hardcoded_token', }); }); + it('create repository specific tokens', async () => { octokit.apps.listInstallations.mockResolvedValue({ headers: { @@ -66,11 +67,6 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => { { id: 1, repository_selection: 'selected', - account: null, - }, - { - id: 2, - repository_selection: 'selected', account: { login: 'backstage', }, @@ -195,9 +191,14 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => { data: { expires_at: DateTime.local().plus({ hours: 1 }).toString(), token: 'secret_token', + repository_selection: 'selected', }, } as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']); + octokit.apps.listReposAccessibleToInstallation.mockReturnValue({ + data: [{ name: 'some-repo' }], + } as unknown as RestEndpointMethodTypes['apps']['listReposAccessibleToInstallation']['response']); + const { token, headers } = await github.getCredentials({ url: 'https://github.com/backstage', }); @@ -402,7 +403,7 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => { }); it('should cache access token', async () => { - octokit.apps.listInstallations.mockResolvedValueOnce({ + octokit.apps.listInstallations.mockReturnValue({ headers: { etag: '123', }, @@ -417,19 +418,19 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => { ], } as RestEndpointMethodTypes['apps']['listInstallations']['response']); - octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({ + octokit.apps.createInstallationAccessToken.mockReturnValue({ data: { expires_at: DateTime.local().plus({ hours: 1 }).toString(), token: 'secret_token', }, } as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']); - for (let i = 0; i < 2; i++) { - const { token, headers } = await github.getCredentials({ - url: 'https://github.com/backstage', - }); - expect(headers).toEqual({ Authorization: 'Bearer secret_token' }); - expect(token).toEqual('secret_token'); - } + await github.getCredentials({ url: 'https://github.com/backstage' }); + await github.getCredentials({ url: 'https://github.com/backstage' }); + + expect(octokit.apps.listInstallations.mock.calls.length).toBe(1); + expect(octokit.apps.createInstallationAccessToken.mock.calls.length).toBe( + 1, + ); }); }); diff --git a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts index 5e98ddd3b1..a502590a17 100644 --- a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts +++ b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts @@ -30,29 +30,52 @@ type InstallationData = { suspended: boolean; }; +type InstallationTokenData = { + token: string; + expiresAt: DateTime; + repositories?: String[]; +}; + class Cache { - private readonly tokenCache = new Map< - string, - { token: string; expiresAt: DateTime } - >(); + private readonly tokenCache = new Map(); async getOrCreateToken( - key: string, - supplier: () => Promise<{ token: string; expiresAt: DateTime }>, + owner: string, + repo: string | undefined, + supplier: () => Promise, ): Promise<{ accessToken: string }> { - const item = this.tokenCache.get(key); - if (item && this.isNotExpired(item.expiresAt)) { - return { accessToken: item.token }; + let ownerData = this.tokenCache.get(owner); + + if (!ownerData || !this.isNotExpired(ownerData.expiresAt)) { + ownerData = await supplier(); + this.tokenCache.set(owner, ownerData); } - const result = await supplier(); - this.tokenCache.set(key, result); - return { accessToken: result.token }; + if (!this.appliesToRepo(ownerData, repo)) { + throw new Error( + `The Backstage GitHub application used in the ${owner} organization does not have access to a repository with the name ${repo}`, + ); + } + + return { accessToken: ownerData.token }; } // consider timestamps older than 50 minutes to be expired. private isNotExpired = (date: DateTime) => date.diff(DateTime.local(), 'minutes').minutes > 50; + + private appliesToRepo(tokenData: InstallationTokenData, repo?: string) { + // If no specific repo has been requested the token is applicable + if (repo === undefined) { + return true; + } + // If the token is restricted to repositories, the token only applies if the repo is in the allow list + if (tokenData.repositories !== undefined) { + return tokenData.repositories.includes(repo); + } + // Otherwise the token is applicable + return true; + } } /** @@ -99,10 +122,8 @@ class GithubAppManager { } } - const cacheKey = repo ? `${owner}/${repo}` : owner; - // 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 () => { + return this.cache.getOrCreateToken(owner, repo, async () => { const { installationId, suspended } = await this.getInstallationData( owner, ); @@ -115,7 +136,9 @@ class GithubAppManager { headers: HEADERS, }); - if (repo && result.data.repository_selection === 'selected') { + let repositoryNames; + + if (result.data.repository_selection === 'selected') { const installationClient = new Octokit({ baseUrl: this.baseUrl, auth: result.data.token, @@ -126,18 +149,13 @@ class GithubAppManager { // The return type of the paginate method is incorrect. const repositories: RestEndpointMethodTypes['apps']['listReposAccessibleToInstallation']['response']['data']['repositories'] = repos.repositories ?? repos; - const hasRepo = repositories.some(repository => { - return repository.name === repo; - }); - if (!hasRepo) { - throw new Error( - `The Backstage GitHub application used in the ${owner} organization does not have access to a repository with the name ${repo}`, - ); - } + + repositoryNames = repositories.map(repository => repository.name); } return { token: result.data.token, expiresAt: DateTime.fromISO(result.data.expires_at), + repositories: repositoryNames, }; }); }