From f2b7585824476b932eb497c89f57b8dceca1d546 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Thu, 11 Dec 2025 09:33:46 +0100 Subject: [PATCH] feat: support marking a github app as a public token generator Signed-off-by: benjdlambert --- packages/integration/config.d.ts | 4 + ...eInstanceGithubCredentialsProvider.test.ts | 326 ++++++++++++++++++ ...SingleInstanceGithubCredentialsProvider.ts | 44 +++ packages/integration/src/github/config.ts | 5 + 4 files changed, 379 insertions(+) diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index a601d63472..a49c4cc0d6 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -330,6 +330,10 @@ export interface Config { * 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; }>; }>; diff --git a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts index 0468e96e14..fefccf22f6 100644 --- a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts @@ -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'); + }); + }); }); diff --git a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts index d2c1e37a65..40d166b223 100644 --- a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts +++ b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts @@ -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) { diff --git a/packages/integration/src/github/config.ts b/packages/integration/src/github/config.ts index 74a0267d62..f755b84c2a 100644 --- a/packages/integration/src/github/config.ts +++ b/packages/integration/src/github/config.ts @@ -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)) {