Share access token between repos in the same app
Signed-off-by: Alex Crome <afscrome@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user