Share access token between repos in the same app

Signed-off-by: Alex Crome <afscrome@users.noreply.github.com>
This commit is contained in:
Alex Crome
2022-09-06 09:26:28 +01:00
parent d57e5f182c
commit 11904b7616
2 changed files with 57 additions and 38 deletions
@@ -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,
);
});
});
@@ -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<string, InstallationTokenData>();
async getOrCreateToken(
key: string,
supplier: () => Promise<{ token: string; expiresAt: DateTime }>,
owner: string,
repo: string | undefined,
supplier: () => Promise<InstallationTokenData>,
): 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,
};
});
}