Fix SingleInstanceGithubCredentialsProvider to return app credentials for bare host URLs

Signed-off-by: Vincenzo Scamporlino <vincenzos@spotify.com>
This commit is contained in:
Vincenzo Scamporlino
2026-03-30 21:35:40 +02:00
parent 40c8ec9615
commit d1124998ca
3 changed files with 100 additions and 0 deletions
+5
View File
@@ -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.
@@ -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();
});
});
});
@@ -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(