diff --git a/.changeset/six-suits-write.md b/.changeset/six-suits-write.md new file mode 100644 index 0000000000..3789c6342f --- /dev/null +++ b/.changeset/six-suits-write.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': patch +--- + +Fixed `SingleInstanceGithubCredentialsProvider` to return app credentials when `getCredentials` is called with a bare host URL (e.g. `https://github.com`) instead of falling back to a personal access token. diff --git a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts index fefccf22f6..ebd884e783 100644 --- a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts +++ b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.test.ts @@ -25,6 +25,12 @@ const octokit = { }, }; +const mockCreateAppAuth = jest.fn(); + +jest.mock('@octokit/auth-app', () => ({ + createAppAuth: (...args: any[]) => mockCreateAppAuth(...args), +})); + jest.mock('@octokit/rest', () => { class Octokit { constructor() { @@ -43,6 +49,12 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => { beforeEach(() => { jest.resetAllMocks(); + mockCreateAppAuth.mockReturnValue(async (opts: { type: string }) => { + if (opts.type === 'app') { + return { token: 'mock-jwt-token' }; + } + throw new Error(`Unexpected auth type: ${opts.type}`); + }); github = SingleInstanceGithubCredentialsProvider.create({ host: 'github.com', apps: [ @@ -889,4 +901,76 @@ describe('SingleInstanceGithubCredentialsProvider tests', () => { expect(token).toEqual('public_access_from_app_2'); }); }); + + describe('bare host URL (no org/repo)', () => { + it('should return app JWT when URL has no org or repo', async () => { + const { token, headers, type } = await github.getCredentials({ + url: 'https://github.com', + }); + + expect(type).toEqual('app'); + expect(token).toEqual('mock-jwt-token'); + expect(headers).toEqual({ + Authorization: 'Bearer mock-jwt-token', + }); + }); + + it('should return app JWT with multiple apps configured', async () => { + const multiAppProvider = SingleInstanceGithubCredentialsProvider.create({ + host: 'github.com', + apps: [ + { + appId: 1, + privateKey: 'privateKey', + webhookSecret: '123', + clientId: 'CLIENT_ID', + clientSecret: 'CLIENT_SECRET', + }, + { + appId: 2, + privateKey: 'privateKey2', + webhookSecret: '456', + clientId: 'CLIENT_ID_2', + clientSecret: 'CLIENT_SECRET_2', + }, + ], + }); + + const { token, type } = await multiAppProvider.getCredentials({ + url: 'https://github.com', + }); + + expect(type).toEqual('app'); + expect(token).toBeDefined(); + }); + + it('should fall back to configured token when no apps are configured and URL has no org', async () => { + const githubProvider = SingleInstanceGithubCredentialsProvider.create({ + host: 'github.com', + apps: [], + token: 'fallback_token', + }); + + const { token, type } = await githubProvider.getCredentials({ + url: 'https://github.com', + }); + + expect(type).toEqual('token'); + expect(token).toEqual('fallback_token'); + }); + + it('should return undefined token when no apps and no token configured for bare host URL', async () => { + const githubProvider = SingleInstanceGithubCredentialsProvider.create({ + host: 'github.com', + }); + + const { token, headers, type } = await githubProvider.getCredentials({ + url: 'https://github.com', + }); + + expect(type).toEqual('token'); + expect(token).toBeUndefined(); + expect(headers).toBeUndefined(); + }); + }); }); diff --git a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts index 40d166b223..f48434bbf6 100644 --- a/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts +++ b/packages/integration/src/github/SingleInstanceGithubCredentialsProvider.ts @@ -124,6 +124,17 @@ class GithubAppManager { owner: string, repo?: string, ): Promise<{ accessToken: string | undefined }> { + // No owner means a bare host URL (e.g. https://github.com) — return an + // app-level JWT rather than an installation token. + if (!owner) { + const auth = createAppAuth({ + appId: this.baseAuthConfig.appId, + privateKey: this.baseAuthConfig.privateKey, + }); + const { token } = await auth({ type: 'app' }); + return { accessToken: token }; + } + if (this.allowedInstallationOwners) { if ( !this.allowedInstallationOwners?.includes(