Merge pull request #4058 from backstage/mob/github-app-manager

Add GithubCredentialsProvider for start of GitHub Apps support
This commit is contained in:
Johan Haals
2021-01-18 08:10:26 +01:00
committed by GitHub
11 changed files with 851 additions and 103 deletions
+10
View File
@@ -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<{
+5 -1
View File
@@ -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": [
@@ -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 });
});
});
@@ -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<InstallationData> {
// 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<string | undefined> {
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<GithubCredentials> {
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,
};
}
}
+43 -1
View File
@@ -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 };
}
/**
+1
View File
@@ -20,3 +20,4 @@ export {
} from './config';
export type { GitHubIntegrationConfig } from './config';
export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core';
export { GithubCredentialsProvider } from './GithubCredentialsProvider';