feat: support marking a github app as a public token generator

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-12-11 09:33:46 +01:00
parent 08d56dd72d
commit f2b7585824
4 changed files with 379 additions and 0 deletions
@@ -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)) {