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

@@ -1,9 +1,11 @@
import {
IconCode,
IconGrid3x3,
IconKey,
IconMessage,
IconPassword,
IconPasswordUser,
IconPlug,
IconServer,
IconUser,
} from "@tabler/icons-react";
@@ -21,4 +23,7 @@ export const integrationSecretIcons = {
topic: IconMessage,
opnsenseApiKey: IconKey,
opnsenseApiSecret: IconPassword,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
} satisfies Record<IntegrationSecretKind, TablerIcon>;

View File

@@ -1,7 +1,7 @@
"use client";
import type { ChangeEventHandler, FocusEventHandler } from "react";
import { PasswordInput, TextInput } from "@mantine/core";
import { PasswordInput, Textarea, TextInput } from "@mantine/core";
import { integrationSecretKindObject } from "@homarr/definitions";
import type { IntegrationSecretKind } from "@homarr/definitions";
@@ -14,9 +14,9 @@ interface IntegrationSecretInputProps {
label?: string;
kind: IntegrationSecretKind;
value?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
onFocus?: FocusEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onChange: ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onFocus?: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
onBlur?: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
error?: string;
}
@@ -31,6 +31,19 @@ export const IntegrationSecretInput = (props: IntegrationSecretInputProps) => {
const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n();
const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind];
if (multiline) {
return (
<Textarea
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
autosize
minRows={2}
/>
);
}
return (
<TextInput
@@ -45,6 +58,21 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n();
const Icon = integrationSecretIcons[kind];
const { multiline } = integrationSecretKindObject[kind];
if (multiline) {
return (
<Textarea
{...props}
label={props.label ?? t(`integration.secrets.kind.${kind}.label`)}
description={t("integration.secrets.secureNotice")}
w="100%"
leftSection={<Icon size={20} stroke={1.5} />}
autosize
minRows={2}
/>
);
}
return (
<PasswordInput

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"
}
}
},

28
pnpm-lock.yaml generated
View File

@@ -870,6 +870,9 @@ importers:
next:
specifier: 15.5.2
version: 15.5.2(@babel/core@7.28.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(sass@1.92.1)
octokit:
specifier: ^5.0.3
version: 5.0.3
react:
specifier: 19.1.1
version: 19.1.1
@@ -1462,6 +1465,9 @@ importers:
'@jellyfin/sdk':
specifier: ^0.11.0
version: 0.11.0(axios@1.11.0)
'@octokit/auth-app':
specifier: ^8.1.0
version: 8.1.0
maria2:
specifier: ^0.4.1
version: 0.4.1
@@ -3471,12 +3477,12 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@octokit/app@16.0.1':
resolution: {integrity: sha512-kgTeTsWmpUX+s3Fs4EK4w1K+jWCDB6ClxLSWUWTyhlw7+L3jHtuXDR4QtABu2GsmCMdk67xRhruiXotS3ay3Yw==}
'@octokit/app@16.1.0':
resolution: {integrity: sha512-OdKHnm0CYLk8Setr47CATT4YnRTvWkpTYvE+B/l2B0mjszlfOIit3wqPHVslD2jfc1bD4UbO7Mzh6gjCuMZKsA==}
engines: {node: '>= 20'}
'@octokit/auth-app@8.0.1':
resolution: {integrity: sha512-P2J5pB3pjiGwtJX4WqJVYCtNkcZ+j5T2Wm14aJAEIC3WJOrv12jvBley3G1U/XI8q9o1A7QMG54LiFED2BiFlg==}
'@octokit/auth-app@8.1.0':
resolution: {integrity: sha512-6bWhyvLXqCSfHiqlwzn9pScLZ+Qnvh/681GR/UEEPCMIVwfpRDBw0cCzy3/t2Dq8B7W2X/8pBgmw6MOiyE0DXQ==}
engines: {node: '>= 20'}
'@octokit/auth-oauth-app@9.0.1':
@@ -11828,9 +11834,9 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.17.1
'@octokit/app@16.0.1':
'@octokit/app@16.1.0':
dependencies:
'@octokit/auth-app': 8.0.1
'@octokit/auth-app': 8.1.0
'@octokit/auth-unauthenticated': 7.0.1
'@octokit/core': 7.0.2
'@octokit/oauth-app': 8.0.1
@@ -11838,7 +11844,7 @@ snapshots:
'@octokit/types': 14.1.0
'@octokit/webhooks': 14.0.0
'@octokit/auth-app@8.0.1':
'@octokit/auth-app@8.1.0':
dependencies:
'@octokit/auth-oauth-app': 9.0.1
'@octokit/auth-oauth-user': 6.0.0
@@ -11885,7 +11891,7 @@ snapshots:
'@octokit/graphql': 9.0.1
'@octokit/request': 10.0.2
'@octokit/request-error': 7.0.0
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
before-after-hook: 4.0.0
universal-user-agent: 7.0.2
@@ -11944,13 +11950,13 @@ snapshots:
dependencies:
'@octokit/core': 7.0.2
'@octokit/request-error': 7.0.0
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
bottleneck: 2.19.5
'@octokit/plugin-throttling@11.0.1(@octokit/core@7.0.2)':
dependencies:
'@octokit/core': 7.0.2
'@octokit/types': 14.0.0
'@octokit/types': 14.1.0
bottleneck: 2.19.5
'@octokit/request-error@7.0.0':
@@ -17406,7 +17412,7 @@ snapshots:
octokit@5.0.3:
dependencies:
'@octokit/app': 16.0.1
'@octokit/app': 16.1.0
'@octokit/core': 7.0.2
'@octokit/oauth-app': 8.0.1
'@octokit/plugin-paginate-graphql': 6.0.0(@octokit/core@7.0.2)