feat(integration): add github app authentication (#3968)

This commit is contained in:
Meier Lukas
2025-09-10 21:17:36 +02:00
committed by GitHub
parent 4d57c7ca13
commit bfcbffbdc6
14 changed files with 282 additions and 77 deletions

View File

@@ -25,16 +25,6 @@ export const testConnectionAsync = async (
integrationUrl: integration.url,
});
const formSecrets = integration.secrets
.filter((secret) => secret.value !== null)
.map((secret) => ({
...secret,
// We ensured above that the value is not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
value: secret.value!,
source: "form" as const,
}));
const decryptedDbSecrets = dbSecrets
.map((secret) => {
try {
@@ -55,6 +45,15 @@ export const testConnectionAsync = async (
})
.filter((secret) => secret !== null);
const formSecrets = integration.secrets
.map((secret) => ({
...secret,
// If the value is not defined in the form (because we only changed other values) we use the existing value from the db if it exists
value: secret.value ?? decryptedDbSecrets.find((dbSecret) => dbSecret.kind === secret.kind)?.value ?? null,
source: "form" as const,
}))
.filter((secret): secret is SourcedIntegrationSecret<"form"> => secret.value !== null);
const sourcedSecrets = [...formSecrets, ...decryptedDbSecrets];
const secretKinds = getSecretKindOption(integration.kind, sourcedSecrets);
@@ -89,10 +88,10 @@ export const testConnectionAsync = async (
return result;
};
interface SourcedIntegrationSecret {
interface SourcedIntegrationSecret<TSource extends string = "db" | "form"> {
kind: IntegrationSecretKind;
value: string;
source: "db" | "form";
source: TSource;
}
const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedIntegrationSecret[]) => {
@@ -111,7 +110,9 @@ const getSecretKindOption = (kind: IntegrationKind, sourcedSecrets: SourcedInteg
}
const onlyFormSecretsKindOptions = matchingSecretKindOptions.filter((secretKinds) =>
sourcedSecrets.filter((secret) => secretKinds.includes(secret.kind)).every((secret) => secret.source === "form"),
secretKinds.every((secretKind) =>
sourcedSecrets.find((secret) => secret.kind === secretKind && secret.source === "form"),
),
);
if (onlyFormSecretsKindOptions.length >= 1) {

View File

@@ -265,4 +265,77 @@ describe("testConnectionAsync should run test connection of integration", () =>
],
});
});
test("with input of existing github app", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([[], ["githubAppId", "githubInstallationId", "privateKey"]]);
const integration = {
id: "new",
name: "GitHub",
url: "https://api.github.com",
kind: "github" as const,
secrets: [
{
kind: "githubAppId" as const,
value: "345",
},
{
kind: "githubInstallationId" as const,
value: "456",
},
{
kind: "privateKey" as const,
value: null,
},
],
};
const dbSecrets = [
{
kind: "githubAppId" as const,
value: "123.encrypted" as const,
},
{
kind: "githubInstallationId" as const,
value: "234.encrypted" as const,
},
{
kind: "privateKey" as const,
value: "privateKey.encrypted" as const,
},
];
// Act
await testConnectionAsync(integration, dbSecrets);
// Assert
expect(factorySpy).toHaveBeenCalledWith({
id: "new",
name: "GitHub",
url: "https://api.github.com",
kind: "github" as const,
decryptedSecrets: [
expect.objectContaining({
kind: "githubAppId",
value: "345",
}),
expect.objectContaining({
kind: "githubInstallationId",
value: "456",
}),
expect.objectContaining({
kind: "privateKey",
value: "privateKey",
}),
],
});
});
});

View File

@@ -33,6 +33,7 @@
"dayjs": "^1.11.18",
"dns-caching": "^0.2.5",
"next": "15.5.2",
"octokit": "^5.0.3",
"react": "19.1.1",
"react-dom": "19.1.1",
"undici": "7.15.0",

View File

@@ -3,3 +3,4 @@ export * from "./fetch-http-error-handler";
export * from "./ofetch-http-error-handler";
export * from "./axios-http-error-handler";
export * from "./tsdav-http-error-handler";
export * from "./octokit-http-error-handler";

View File

@@ -0,0 +1,24 @@
import { RequestError as OctokitRequestError } from "octokit";
import type { AnyRequestError } from "../request-error";
import { ResponseError } from "../response-error";
import { HttpErrorHandler } from "./http-error-handler";
export class OctokitHttpErrorHandler extends HttpErrorHandler {
/**
* I wasn't able to get a request error triggered. Therefore we ignore them for now
* and just forward them as unknown errors
*/
handleRequestError(_: unknown): AnyRequestError | undefined {
return undefined;
}
handleResponseError(error: unknown): ResponseError | undefined {
if (!(error instanceof OctokitRequestError)) return undefined;
return new ResponseError({
status: error.status,
url: error.response?.url,
});
}
}

View File

@@ -4,16 +4,19 @@ import type { AtLeastOneOf } from "@homarr/common/types";
import { createDocumentationLink } from "./docs";
export const integrationSecretKindObject = {
apiKey: { isPublic: false },
username: { isPublic: true },
password: { isPublic: false },
tokenId: { isPublic: true },
realm: { isPublic: true },
personalAccessToken: { isPublic: false },
topic: { isPublic: true },
opnsenseApiKey: { isPublic: false },
opnsenseApiSecret: { isPublic: false },
} satisfies Record<string, { isPublic: boolean }>;
apiKey: { isPublic: false, multiline: false },
username: { isPublic: true, multiline: false },
password: { isPublic: false, multiline: false },
tokenId: { isPublic: true, multiline: false },
realm: { isPublic: true, multiline: false },
personalAccessToken: { isPublic: false, multiline: false },
topic: { isPublic: true, multiline: false },
opnsenseApiKey: { isPublic: false, multiline: false },
opnsenseApiSecret: { isPublic: false, multiline: false },
privateKey: { isPublic: false, multiline: true },
githubAppId: { isPublic: true, multiline: false },
githubInstallationId: { isPublic: true, multiline: false },
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
@@ -211,7 +214,7 @@ export const integrationDefs = {
},
github: {
name: "Github",
secretKinds: [[], ["personalAccessToken"]],
secretKinds: [[], ["personalAccessToken"], ["githubAppId", "githubInstallationId", "privateKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
category: ["releasesProvider"],
defaultUrl: "https://api.github.com",
@@ -259,7 +262,7 @@ export const integrationDefs = {
},
gitHubContainerRegistry: {
name: "GitHub Container Registry",
secretKinds: [[], ["personalAccessToken"]],
secretKinds: [[], ["personalAccessToken"], ["githubAppId", "githubInstallationId", "privateKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/github.svg",
category: ["releasesProvider"],
defaultUrl: "https://api.github.com",

View File

@@ -40,6 +40,7 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@jellyfin/sdk": "^0.11.0",
"@octokit/auth-app": "^8.1.0",
"maria2": "^0.4.1",
"node-ical": "^0.20.1",
"octokit": "^5.0.3",

View File

@@ -1,6 +1,7 @@
import {
AxiosHttpErrorHandler,
FetchHttpErrorHandler,
OctokitHttpErrorHandler,
OFetchHttpErrorHandler,
TsdavHttpErrorHandler,
} from "@homarr/common/server";
@@ -11,3 +12,4 @@ export const integrationFetchHttpErrorHandler = new IntegrationHttpErrorHandler(
export const integrationOFetchHttpErrorHandler = new IntegrationHttpErrorHandler(new OFetchHttpErrorHandler());
export const integrationAxiosHttpErrorHandler = new IntegrationHttpErrorHandler(new AxiosHttpErrorHandler());
export const integrationTsdavHttpErrorHandler = new IntegrationHttpErrorHandler(new TsdavHttpErrorHandler());
export const integrationOctokitHttpErrorHandler = new IntegrationHttpErrorHandler(new OctokitHttpErrorHandler());

View File

@@ -1,11 +1,14 @@
import { createAppAuth } from "@octokit/auth-app";
import { Octokit, RequestError } from "octokit";
import type { fetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import { integrationOctokitHttpErrorHandler } from "../base/errors/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
@@ -18,23 +21,21 @@ import type {
const localLogger = logger.child({ module: "GitHubContainerRegistryIntegration" });
@HandleIntegrationErrors([integrationOctokitHttpErrorHandler])
export class GitHubContainerRegistryIntegration extends Integration implements ReleasesProviderIntegration {
private static readonly userAgent = "Homarr-Lab/Homarr:GitHubContainerRegistryIntegration";
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const headers: RequestInit["headers"] = {
"User-Agent": GitHubContainerRegistryIntegration.userAgent,
};
const api = this.getApi(input.fetchAsync);
if (this.hasSecretValue("personalAccessToken"))
headers.Authorization = `Bearer ${this.getSecretValue("personalAccessToken")}`;
const response = await input.fetchAsync(this.url("/octocat"), {
headers,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
if (this.hasSecretValue("personalAccessToken")) {
await api.rest.users.getAuthenticated();
} else if (this.hasSecretValue("githubAppId")) {
await api.rest.apps.getInstallation({
installation_id: Number(this.getSecretValue("githubInstallationId")),
});
} else {
await api.request("GET /octocat");
}
return {
@@ -131,15 +132,38 @@ export class GitHubContainerRegistryIntegration extends Integration implements R
}
}
private getApi() {
private getAuthProperties(): Pick<OctokitOptions, "auth" | "authStrategy"> {
if (this.hasSecretValue("personalAccessToken"))
return {
auth: this.getSecretValue("personalAccessToken"),
};
if (this.hasSecretValue("githubAppId"))
return {
authStrategy: createAppAuth,
auth: {
appId: this.getSecretValue("githubAppId"),
installationId: this.getSecretValue("githubInstallationId"),
privateKey: this.getSecretValue("privateKey"),
} satisfies Parameters<typeof createAppAuth>[0],
};
return {};
}
private getApi(customFetch?: typeof fetch) {
return new Octokit({
baseUrl: this.url("/").origin,
request: {
fetch: fetchWithTrustedCertificatesAsync,
fetch: customFetch ?? fetchWithTrustedCertificatesAsync,
},
userAgent: GitHubContainerRegistryIntegration.userAgent,
throttle: { enabled: false }, // Disable throttling for this integration, Octokit will retry by default after a set time, thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
...(this.hasSecretValue("personalAccessToken") ? { auth: this.getSecretValue("personalAccessToken") } : {}),
// Disable throttling for this integration, Octokit will retry by default after a set time,
// thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
throttle: { enabled: false },
...this.getAuthProperties(),
});
}
}
type OctokitOptions = Exclude<ConstructorParameters<typeof Octokit>[0], undefined>;

View File

@@ -1,11 +1,14 @@
import { Octokit, RequestError } from "octokit";
import { createAppAuth } from "@octokit/auth-app";
import { Octokit, RequestError as OctokitRequestError } from "octokit";
import type { fetch } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { logger } from "@homarr/log";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import { integrationOctokitHttpErrorHandler } from "../base/errors/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ReleasesProviderIntegration } from "../interfaces/releases-providers/releases-providers-integration";
import { getLatestRelease } from "../interfaces/releases-providers/releases-providers-integration";
@@ -18,23 +21,21 @@ import type {
const localLogger = logger.child({ module: "GithubIntegration" });
@HandleIntegrationErrors([integrationOctokitHttpErrorHandler])
export class GithubIntegration extends Integration implements ReleasesProviderIntegration {
private static readonly userAgent = "Homarr-Lab/Homarr:GithubIntegration";
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const headers: RequestInit["headers"] = {
"User-Agent": GithubIntegration.userAgent,
};
const api = this.getApi(input.fetchAsync);
if (this.hasSecretValue("personalAccessToken"))
headers.Authorization = `Bearer ${this.getSecretValue("personalAccessToken")}`;
const response = await input.fetchAsync(this.url("/octocat"), {
headers,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
if (this.hasSecretValue("personalAccessToken")) {
await api.rest.users.getAuthenticated();
} else if (this.hasSecretValue("githubAppId")) {
await api.rest.apps.getInstallation({
installation_id: Number(this.getSecretValue("githubInstallationId")),
});
} else {
await api.request("GET /octocat");
}
return {
@@ -91,7 +92,7 @@ export class GithubIntegration extends Integration implements ReleasesProviderIn
return getLatestRelease(releasesProviderResponse, repository, details);
} catch (error) {
const errorMessage = error instanceof RequestError ? error.message : String(error);
const errorMessage = error instanceof OctokitRequestError ? error.message : String(error);
localLogger.warn(`Failed to get releases for ${owner}\\${name} with Github integration`, {
owner,
@@ -131,21 +132,44 @@ export class GithubIntegration extends Integration implements ReleasesProviderIn
localLogger.warn(`Failed to get details for ${owner}\\${name} with Github integration`, {
owner,
name,
error: error instanceof RequestError ? error.message : String(error),
error: error instanceof OctokitRequestError ? error.message : String(error),
});
return undefined;
}
}
private getApi() {
private getAuthProperties(): Pick<OctokitOptions, "auth" | "authStrategy"> {
if (this.hasSecretValue("personalAccessToken"))
return {
auth: this.getSecretValue("personalAccessToken"),
};
if (this.hasSecretValue("githubAppId"))
return {
authStrategy: createAppAuth,
auth: {
appId: this.getSecretValue("githubAppId"),
installationId: this.getSecretValue("githubInstallationId"),
privateKey: this.getSecretValue("privateKey"),
} satisfies Parameters<typeof createAppAuth>[0],
};
return {};
}
private getApi(customFetch?: typeof fetch) {
return new Octokit({
baseUrl: this.url("/").origin,
request: {
fetch: fetchWithTrustedCertificatesAsync,
fetch: customFetch ?? fetchWithTrustedCertificatesAsync,
},
userAgent: GithubIntegration.userAgent,
throttle: { enabled: false }, // Disable throttling for this integration, Octokit will retry by default after a set time, thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
...(this.hasSecretValue("personalAccessToken") ? { auth: this.getSecretValue("personalAccessToken") } : {}),
// Disable throttling for this integration, Octokit will retry by default after a set time,
// thus delaying the repsonse to the user in case of errors. Errors will be shown to the user, no need to retry the request.
throttle: { enabled: false },
...this.getAuthProperties(),
});
}
}
type OctokitOptions = Exclude<ConstructorParameters<typeof Octokit>[0], undefined>;

View File

@@ -952,6 +952,18 @@
"opnsenseApiSecret": {
"label": "API Key (Secret)",
"newLabel": "New API Key (Secret)"
},
"githubAppId": {
"label": "App Id",
"newLabel": "New App Id"
},
"githubInstallationId": {
"label": "Installation Id",
"newLabel": "New Installation Id"
},
"privateKey": {
"label": "Private key",
"newLabel": "New private key"
}
}
},