feat: support marking a github app as a public token generator
Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -563,4 +563,330 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => {
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
describe('public access', () => {
|
||||
it('should use an installation token when public access is enabled and owner is not in allowed list', async () => {
|
||||
const githubProvider = SingleInstanceGithubCredentialsProvider.create({
|
||||
host: 'github.com',
|
||||
apps: [
|
||||
{
|
||||
appId: 1,
|
||||
privateKey: 'privateKey',
|
||||
webhookSecret: '123',
|
||||
clientId: 'CLIENT_ID',
|
||||
clientSecret: 'CLIENT_SECRET',
|
||||
allowedInstallationOwners: ['other-org'],
|
||||
publicAccess: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
octokit.apps.listInstallations.mockResolvedValue({
|
||||
headers: {
|
||||
etag: '123',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
repository_selection: 'all',
|
||||
account: {
|
||||
login: 'other-org',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
|
||||
|
||||
octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({
|
||||
data: {
|
||||
expires_at: DateTime.local().plus({ hours: 1 }).toString(),
|
||||
token: 'public_access_token',
|
||||
},
|
||||
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
|
||||
|
||||
const { token, headers, type } = await githubProvider.getCredentials({
|
||||
url: 'https://github.com/some-public-org/some-repo',
|
||||
});
|
||||
|
||||
expect(type).toEqual('app');
|
||||
expect(token).toEqual('public_access_token');
|
||||
expect(headers).toEqual({ Authorization: 'Bearer public_access_token' });
|
||||
});
|
||||
|
||||
it('should use an installation token when public access is enabled and no installation exists for owner', async () => {
|
||||
const githubProvider = SingleInstanceGithubCredentialsProvider.create({
|
||||
host: 'github.com',
|
||||
apps: [
|
||||
{
|
||||
appId: 1,
|
||||
privateKey: 'privateKey',
|
||||
webhookSecret: '123',
|
||||
clientId: 'CLIENT_ID',
|
||||
clientSecret: 'CLIENT_SECRET',
|
||||
publicAccess: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
octokit.apps.listInstallations.mockResolvedValue({
|
||||
headers: {
|
||||
etag: '123',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 42,
|
||||
repository_selection: 'all',
|
||||
account: {
|
||||
login: 'installed-org',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
|
||||
|
||||
octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({
|
||||
data: {
|
||||
expires_at: DateTime.local().plus({ hours: 1 }).toString(),
|
||||
token: 'public_installation_token',
|
||||
},
|
||||
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
|
||||
|
||||
const { token, headers, type } = await githubProvider.getCredentials({
|
||||
url: 'https://github.com/non-installed-org/some-repo',
|
||||
});
|
||||
|
||||
expect(type).toEqual('app');
|
||||
expect(token).toEqual('public_installation_token');
|
||||
expect(headers).toEqual({
|
||||
Authorization: 'Bearer public_installation_token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not use public access when normal installation credentials are available', async () => {
|
||||
const githubProvider = SingleInstanceGithubCredentialsProvider.create({
|
||||
host: 'github.com',
|
||||
apps: [
|
||||
{
|
||||
appId: 1,
|
||||
privateKey: 'privateKey',
|
||||
webhookSecret: '123',
|
||||
clientId: 'CLIENT_ID',
|
||||
clientSecret: 'CLIENT_SECRET',
|
||||
publicAccess: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
octokit.apps.listInstallations.mockResolvedValue({
|
||||
headers: {
|
||||
etag: '123',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
repository_selection: 'all',
|
||||
account: {
|
||||
login: 'backstage',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
|
||||
|
||||
octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({
|
||||
data: {
|
||||
expires_at: DateTime.local().plus({ hours: 1 }).toString(),
|
||||
token: 'normal_token',
|
||||
},
|
||||
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
|
||||
|
||||
const { token, type } = await githubProvider.getCredentials({
|
||||
url: 'https://github.com/backstage/repo',
|
||||
});
|
||||
|
||||
expect(type).toEqual('app');
|
||||
expect(token).toEqual('normal_token');
|
||||
// createInstallationAccessToken should only be called once for the normal flow
|
||||
expect(octokit.apps.createInstallationAccessToken).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to configured token when public access fails', async () => {
|
||||
const githubProvider = SingleInstanceGithubCredentialsProvider.create({
|
||||
host: 'github.com',
|
||||
apps: [
|
||||
{
|
||||
appId: 1,
|
||||
privateKey: 'privateKey',
|
||||
webhookSecret: '123',
|
||||
clientId: 'CLIENT_ID',
|
||||
clientSecret: 'CLIENT_SECRET',
|
||||
allowedInstallationOwners: ['other-org'],
|
||||
publicAccess: true,
|
||||
},
|
||||
],
|
||||
token: 'fallback_token',
|
||||
});
|
||||
|
||||
octokit.apps.listInstallations.mockResolvedValue({
|
||||
headers: {
|
||||
etag: '123',
|
||||
},
|
||||
data: [],
|
||||
} as unknown as RestEndpointMethodTypes['apps']['listInstallations']['response']);
|
||||
|
||||
const { token, type } = await githubProvider.getCredentials({
|
||||
url: 'https://github.com/some-org/repo',
|
||||
});
|
||||
|
||||
expect(type).toEqual('token');
|
||||
expect(token).toEqual('fallback_token');
|
||||
});
|
||||
|
||||
it('should return undefined when public access is disabled and no installation exists', async () => {
|
||||
const githubProvider = SingleInstanceGithubCredentialsProvider.create({
|
||||
host: 'github.com',
|
||||
apps: [
|
||||
{
|
||||
appId: 1,
|
||||
privateKey: 'privateKey',
|
||||
webhookSecret: '123',
|
||||
clientId: 'CLIENT_ID',
|
||||
clientSecret: 'CLIENT_SECRET',
|
||||
allowedInstallationOwners: ['other-org'],
|
||||
// publicAccess is not set (defaults to false)
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
octokit.apps.listInstallations.mockResolvedValue({
|
||||
headers: {
|
||||
etag: '123',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
repository_selection: 'all',
|
||||
account: {
|
||||
login: 'other-org',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
|
||||
|
||||
const { token, headers } = await githubProvider.getCredentials({
|
||||
url: 'https://github.com/some-org/repo',
|
||||
});
|
||||
|
||||
expect(token).toBeUndefined();
|
||||
expect(headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should cache public access tokens separately from regular tokens', async () => {
|
||||
const githubProvider = SingleInstanceGithubCredentialsProvider.create({
|
||||
host: 'github.com',
|
||||
apps: [
|
||||
{
|
||||
appId: 1,
|
||||
privateKey: 'privateKey',
|
||||
webhookSecret: '123',
|
||||
clientId: 'CLIENT_ID',
|
||||
clientSecret: 'CLIENT_SECRET',
|
||||
allowedInstallationOwners: ['installed-org'],
|
||||
publicAccess: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
octokit.apps.listInstallations.mockResolvedValue({
|
||||
headers: {
|
||||
etag: '123',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
repository_selection: 'all',
|
||||
account: {
|
||||
login: 'installed-org',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
|
||||
|
||||
octokit.apps.createInstallationAccessToken.mockResolvedValue({
|
||||
data: {
|
||||
expires_at: DateTime.local().plus({ hours: 1 }).toString(),
|
||||
token: 'public_token',
|
||||
},
|
||||
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
|
||||
|
||||
// First call for a public org
|
||||
await githubProvider.getCredentials({
|
||||
url: 'https://github.com/public-org/repo',
|
||||
});
|
||||
|
||||
// Second call for the same public org should use cached token
|
||||
await githubProvider.getCredentials({
|
||||
url: 'https://github.com/public-org/repo',
|
||||
});
|
||||
|
||||
// createInstallationAccessToken should only be called once due to caching
|
||||
expect(octokit.apps.createInstallationAccessToken).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use public access with multiple apps when only one has publicAccess enabled', async () => {
|
||||
const githubProvider = SingleInstanceGithubCredentialsProvider.create({
|
||||
host: 'github.com',
|
||||
apps: [
|
||||
{
|
||||
appId: 1,
|
||||
privateKey: 'privateKey',
|
||||
webhookSecret: '123',
|
||||
clientId: 'CLIENT_ID',
|
||||
clientSecret: 'CLIENT_SECRET',
|
||||
allowedInstallationOwners: ['org-1'],
|
||||
// publicAccess not set, defaults to false
|
||||
},
|
||||
{
|
||||
appId: 2,
|
||||
privateKey: 'privateKey2',
|
||||
webhookSecret: '456',
|
||||
clientId: 'CLIENT_ID_2',
|
||||
clientSecret: 'CLIENT_SECRET_2',
|
||||
allowedInstallationOwners: ['org-2'],
|
||||
publicAccess: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
octokit.apps.listInstallations.mockResolvedValue({
|
||||
headers: {
|
||||
etag: '123',
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
repository_selection: 'all',
|
||||
account: {
|
||||
login: 'org-2',
|
||||
},
|
||||
},
|
||||
],
|
||||
} as RestEndpointMethodTypes['apps']['listInstallations']['response']);
|
||||
|
||||
octokit.apps.createInstallationAccessToken.mockResolvedValueOnce({
|
||||
data: {
|
||||
expires_at: DateTime.local().plus({ hours: 1 }).toString(),
|
||||
token: 'public_access_from_app_2',
|
||||
},
|
||||
} as RestEndpointMethodTypes['apps']['createInstallationAccessToken']['response']);
|
||||
|
||||
const { token, type } = await githubProvider.getCredentials({
|
||||
url: 'https://github.com/unknown-org/repo',
|
||||
});
|
||||
|
||||
expect(type).toEqual('app');
|
||||
expect(token).toEqual('public_access_from_app_2');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +100,7 @@ class GithubAppManager {
|
||||
private readonly baseAuthConfig: { appId: number; privateKey: string };
|
||||
private readonly cache = new Cache();
|
||||
private readonly allowedInstallationOwners: string[] | undefined; // undefined allows all installations
|
||||
public readonly publicAccess: boolean;
|
||||
|
||||
constructor(config: GithubAppConfig, baseUrl?: string) {
|
||||
this.allowedInstallationOwners = config.allowedInstallationOwners?.map(
|
||||
@@ -116,6 +117,7 @@ class GithubAppManager {
|
||||
authStrategy: createAppAuth,
|
||||
auth: this.baseAuthConfig,
|
||||
});
|
||||
this.publicAccess = config.publicAccess ?? false;
|
||||
}
|
||||
|
||||
async getInstallationCredentials(
|
||||
@@ -170,6 +172,30 @@ class GithubAppManager {
|
||||
});
|
||||
}
|
||||
|
||||
async getPublicInstallationToken(): Promise<{ accessToken: string }> {
|
||||
const [installation] = await this.getInstallations();
|
||||
|
||||
if (!installation) {
|
||||
throw new Error(`No installation found for public app`);
|
||||
}
|
||||
|
||||
return this.cache.getOrCreateToken(
|
||||
`public:${installation.id}`,
|
||||
undefined,
|
||||
async () => {
|
||||
const result = await this.appClient.apps.createInstallationAccessToken({
|
||||
installation_id: installation.id,
|
||||
headers: HEADERS,
|
||||
});
|
||||
|
||||
return {
|
||||
token: result.data.token,
|
||||
expiresAt: DateTime.fromISO(result.data.expires_at),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
getInstallations(): Promise<
|
||||
RestEndpointMethodTypes['apps']['listInstallations']['response']['data']
|
||||
> {
|
||||
@@ -185,12 +211,14 @@ class GithubAppManager {
|
||||
inst.account.login?.toLocaleLowerCase('en-US') ===
|
||||
owner.toLocaleLowerCase('en-US'),
|
||||
);
|
||||
|
||||
if (installation) {
|
||||
return {
|
||||
installationId: installation.id,
|
||||
suspended: Boolean(installation.suspended_by),
|
||||
};
|
||||
}
|
||||
|
||||
const notFoundError = new Error(
|
||||
`No app installation found for ${owner} in ${this.baseAuthConfig.appId}`,
|
||||
);
|
||||
@@ -245,10 +273,26 @@ export class GithubAppCredentialsMux {
|
||||
const result = results.find(
|
||||
resultItem => resultItem.credentials?.accessToken,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
return result.credentials!.accessToken;
|
||||
}
|
||||
|
||||
// If there was no token returned, then let's find a public access app and use an installation to get a token.
|
||||
const publicAccessApp = this.apps.find(app => app.publicAccess);
|
||||
if (publicAccessApp) {
|
||||
const publicResult = await publicAccessApp
|
||||
.getPublicInstallationToken()
|
||||
.then(
|
||||
credentials => ({ credentials, error: undefined }),
|
||||
error => ({ credentials: undefined, error }),
|
||||
);
|
||||
|
||||
if (publicResult.credentials?.accessToken) {
|
||||
return publicResult.credentials.accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
const errors = results.map(r => r.error);
|
||||
const notNotFoundError = errors.find(err => err?.name !== 'NotFoundError');
|
||||
if (notNotFoundError) {
|
||||
|
||||
@@ -109,6 +109,10 @@ export type GithubAppConfig = {
|
||||
* https://docs.github.com/en/rest/reference/apps#list-installations-for-the-authenticated-app--code-samples
|
||||
*/
|
||||
allowedInstallationOwners?: string[];
|
||||
/**
|
||||
* If true, then an installation token will be issued for access when no other token is available.
|
||||
*/
|
||||
publicAccess?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -133,6 +137,7 @@ export function readGithubIntegrationConfig(
|
||||
allowedInstallationOwners: c.getOptionalStringArray(
|
||||
'allowedInstallationOwners',
|
||||
),
|
||||
publicAccess: c.getOptionalBoolean('publicAccess'),
|
||||
}));
|
||||
|
||||
if (!isValidHost(host)) {
|
||||
|
||||
Reference in New Issue
Block a user