feat: reuse authentication for all HTTP integrations

This commit is contained in:
ajnart committed 2026-09-12 23:33:23 +02:00
1 parent 879a4ae23c
commit 901fd3d940
36 files changed
+830 -295

No files matched your search

@@ -9,7 +9,7 @@ request to a different origin.
## Existing integrations
Sources can reuse configured Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Overseerr, Jellyseerr, Seerr, Immich, slskd, Home Assistant, Coolify, Audiobookshelf, Speedtest Tracker, AdGuard Home, and Nextcloud integrations:
Sources can reuse any configured HTTP integration, including its saved credentials and login session. WebSocket-only integrations and the internal mock integration are excluded:
```json
{
@@ -23,7 +23,7 @@ paths append to the saved URL, including reverse-proxy subpaths. Changing the in
Deleting it leaves the widget unconfigured until another instance is selected. The binding is shared by every placement
of that widget definition. Exports and Workshop submissions retain the required type and omit the local integration ID.
Arbitrary integration requests require integration `full` access, including GET requests: service APIs can expose credentials or change state through reads. Board permissions still apply separately. Saved credentials are redacted from upstream responses. Non-GET integration requests must be actions. Requests and redirects stay within the configured URL path.
Arbitrary integration requests require integration `full` access, including GET requests: service APIs can expose credentials or change state through reads. Board permissions still apply separately. Saved credentials are redacted from upstream responses. Non-GET integration requests must be actions. Requests and redirects stay within the configured URL path. Expired sessions are refreshed; queries can retry once, while actions require another explicit invocation.
## Named requests
@@ -32,7 +32,7 @@ import type { WorkshopSubmission, WorkshopVote } from "@site/src/lib/pocketbase"
import { getWorkshopBackend } from "@site/src/lib/pocketbase";
import { getRuntimeWorkshopApiUrl } from "@site/src/lib/runtime-config";
import type { SubmissionType } from "@site/src/lib/workshop-schema";
import { integrationDefs } from "@homarr/definitions";
import { integrationDefs, isHttpIntegrationKind } from "@homarr/definitions";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
import {
githubAvatarUrl,
@@ -100,7 +100,11 @@ const avatarFallback = (name: string) => name.trim().slice(0, 1).toUpperCase() |
type WidgetSource = HomarrCustomWidgetV2["sources"][string];
const sourceHost = (source: WidgetSource) => {
if (source.type === "integration") return `${integrationDefs[source.integrationKind].name} integration`;
if (source.type === "integration") {
if (isHttpIntegrationKind(source.integrationKind))
return `${integrationDefs[source.integrationKind].name} integration`;
return `${source.integrationKind} integration`;
}
const { baseUrl } = source;
try {
return new URL(baseUrl).host;
@@ -1,6 +1,6 @@
"use client";
import { SourceSetupPanel } from "~/components/custom-widgets/source-setup-panel";
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import {
@@ -1,6 +1,6 @@
"use client";
import { SourceSetupPanel } from "~/components/custom-widgets/source-setup-panel";
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
import type { ModalProps } from "@mantine/core";
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
@@ -1,6 +1,6 @@
"use client";
import { SourceSetupPanel } from "~/components/custom-widgets/source-setup-panel";
import { SourceSetupPanel } from "~/components/custom-widgets/integration-source-select";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { Button, Center, Group, Loader, Modal, Stack, Text } from "@mantine/core";
@@ -2,12 +2,16 @@
import { Alert, Select, Stack, Text } from "@mantine/core";
import { CustomWidgetSourceSetupPanel } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetSourceSetupPanelProps } from "@homarr/custom-widgets/workbench";
import { clientApi } from "@homarr/api/client";
import type { CustomWidgetIntegrationSource } from "@homarr/custom-widgets/core";
import { customWidgetIntegrationKinds } from "@homarr/custom-widgets/core";
import { integrationDefs } from "@homarr/definitions";
import { integrationDefs, integrationKinds, isHttpIntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
const httpIntegrationKinds = integrationKinds.filter(isHttpIntegrationKind);
interface IntegrationSourceSelectProps {
kind: CustomWidgetIntegrationSource["integrationKind"];
integrationId?: string;
@@ -27,18 +31,18 @@ export function IntegrationSourceSelect({ kind, integrationId, onChange, onKindC
{onKindChange && (
<Select
label={t("integrationType")}
data={customWidgetIntegrationKinds.map((value) => ({ value, label: integrationDefs[value].name }))}
data={httpIntegrationKinds.map((value) => ({ value, label: integrationDefs[value].name }))}
value={kind}
allowDeselect={false}
onChange={(value) => {
const selected = customWidgetIntegrationKinds.find((candidate) => candidate === value);
const selected = httpIntegrationKinds.find((candidate) => candidate === value);
if (selected) onKindChange(selected);
}}
/>
)}
<Select
label={t("selectIntegration")}
description={integrationDefs[kind].name}
description={isHttpIntegrationKind(kind) ? integrationDefs[kind].name : kind}
data={integrations.map(({ id, name }) => ({ value: id, label: name }))}
value={integrationId ?? null}
searchable
@@ -55,3 +59,22 @@ export function IntegrationSourceSelect({ kind, integrationId, onChange, onKindC
</Stack>
);
}
export function SourceSetupPanel(props: CustomWidgetSourceSetupPanelProps) {
return (
<CustomWidgetSourceSetupPanel
{...props}
renderIntegrationSource={(setup, value) => {
const kind = httpIntegrationKinds.find((candidate) => candidate === setup.integrationKind);
if (!kind) return null;
return (
<IntegrationSourceSelect
kind={kind}
integrationId={value.integrationId}
onChange={(integrationId) => props.onChange(setup.sourceId, { ...value, integrationId })}
/>
);
}}
/>
);
}
@@ -1,26 +0,0 @@
"use client";
import { customWidgetIntegrationKinds } from "@homarr/custom-widgets/core";
import { CustomWidgetSourceSetupPanel } from "@homarr/custom-widgets/workbench";
import type { CustomWidgetSourceSetupPanelProps } from "@homarr/custom-widgets/workbench";
import { IntegrationSourceSelect } from "./integration-source-select";
export function SourceSetupPanel(props: CustomWidgetSourceSetupPanelProps) {
return (
<CustomWidgetSourceSetupPanel
{...props}
renderIntegrationSource={(setup, value) => {
const kind = customWidgetIntegrationKinds.find((candidate) => candidate === setup.integrationKind);
if (!kind) return null;
return (
<IntegrationSourceSelect
kind={kind}
integrationId={value.integrationId}
onChange={(integrationId) => props.onChange(setup.sourceId, { ...value, integrationId })}
/>
);
}}
/>
);
}
@@ -1,9 +1,9 @@
import { TRPCError } from "@trpc/server";
import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core";
import { customWidgetIntegrationKinds, getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import { getCustomWidgetSourceAuthType } from "@homarr/custom-widgets/core";
import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
import type { integrationHttpAuth } from "@homarr/integrations/http-auth";
import { isHttpIntegrationKind } from "@homarr/definitions";
import type { IntegrationHttpContext } from "../integration/integration-http";
import {
@@ -12,9 +12,6 @@ import {
getIntegrationHttpConnection,
} from "../integration/integration-http";
// Keep the portable schema's supported types within the server authentication adapters.
customWidgetIntegrationKinds satisfies readonly (keyof typeof integrationHttpAuth)[];
export async function assertCustomWidgetIntegrationBindings(
ctx: IntegrationHttpContext,
sources: Record<string, CustomWidgetSource>,
@@ -36,7 +33,7 @@ async function resolveIntegration(
});
}
const integration = await getIntegrationForHttpRequest(ctx, source.integrationId);
if (!integration || integration.kind !== source.integrationKind) {
if (!isHttpIntegrationKind(integration.kind) || integration.kind !== source.integrationKind) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "The selected integration is unavailable or has a different type",
@@ -51,7 +48,10 @@ export async function resolveCustomWidgetSource(
request: Pick<CustomJsxRequest, "kind" | "method" | "auth">,
getSecrets: () => Array<{ kind: string; value: string }>,
): Promise<
Pick<CustomWidgetHttpRequest, "baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix" | "redactSecrets"> & {
Pick<
CustomWidgetHttpRequest,
"baseUrl" | "networkScope" | "auth" | "tls" | "pathPrefix" | "redactSecrets" | "resolveConnectionAsync"
> & {
cacheVersion: string;
}
> {
@@ -65,11 +65,7 @@ export async function resolveCustomWidgetSource(
return { baseUrl: source.baseUrl, networkScope: source.networkScope, auth, cacheVersion: "" };
}
const integration = await resolveIntegration(ctx, source);
const connection = await getIntegrationHttpConnection(integration);
return {
...connection,
auth: request.auth === "none" ? undefined : connection.auth,
};
return getIntegrationHttpConnection(integration, request.auth !== "none");
}
export async function getCustomWidgetIntegrationCacheVersions(
@@ -9,11 +9,13 @@ import {
getTrustedCertificateHostnamesAsync,
} from "@homarr/core/infrastructure/certificates";
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
import type { CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
import { CustomWidgetDomainError } from "@homarr/custom-widgets/server";
import type { CustomWidgetAuthConfig, CustomWidgetHttpRequest } from "@homarr/custom-widgets/server";
import type { Database } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrations, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
import { IntegrationHttpAuthError, resolveIntegrationHttpAuth } from "@homarr/integrations/http-auth";
import { getIntegrationHttpAuthenticationAsync } from "@homarr/integrations/factory";
import type { IntegrationHttpAuthentication } from "@homarr/integrations/factory";
export interface IntegrationHttpContext {
db: Database;
@@ -53,32 +55,72 @@ export function getIntegrationHttpCacheVersion(integration: HttpIntegration) {
.slice(0, 16);
}
export async function getIntegrationHttpConnection(integration: HttpIntegration) {
export async function getIntegrationHttpConnection(integration: HttpIntegration, authenticate = true) {
try {
const secrets = integration.secrets.map(({ kind, value }) => ({ kind, value: decryptSecret(value) }));
const auth = resolveIntegrationHttpAuth(integration.kind, secrets);
const url = new URL(integration.url);
let tls: CustomWidgetHttpRequest["tls"];
if (url.protocol === "https:") {
const [ca, hostnames] = await Promise.all([
getAllTrustedCertificatesAsync(),
getTrustedCertificateHostnamesAsync(),
]);
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
}
let baseUrl = integration.url;
if (integration.kind === "ical") baseUrl = secrets.find(({ kind }) => kind === "url")?.value ?? "";
return {
baseUrl: integration.url,
auth,
tls,
baseUrl,
networkScope: "loopback" as const,
pathPrefix: url.pathname,
pathPrefix: new URL(baseUrl).pathname,
redactSecrets: secrets,
cacheVersion: getIntegrationHttpCacheVersion(integration),
resolveConnectionAsync: async () => {
try {
let authentication: IntegrationHttpAuthentication = {};
if (authenticate) {
authentication = await getIntegrationHttpAuthenticationAsync({
...integration,
externalUrl: null,
decryptedSecrets: secrets,
});
}
const auth = toWidgetAuth(authentication, secrets);
const url = new URL(authentication.baseUrl ?? baseUrl);
let tls: CustomWidgetHttpRequest["tls"];
if (url.protocol === "https:") {
const [ca, hostnames] = await Promise.all([
getAllTrustedCertificatesAsync(),
getTrustedCertificateHostnamesAsync(),
]);
tls = { ca, checkServerIdentity: createCustomCheckServerIdentity(hostnames) };
}
return { baseUrl: url.href, auth, tls, pathPrefix: url.pathname, redactSecrets: auth.secrets };
} catch {
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: "Integration connection could not be resolved",
});
}
},
};
} catch (error) {
if (error instanceof IntegrationHttpAuthError) {
throw new TRPCError({ code: error.code, message: error.message });
}
} catch {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Integration connection could not be resolved" });
}
}
function toWidgetAuth(
authentication: IntegrationHttpAuthentication,
secrets: CustomWidgetAuthConfig["secrets"],
): CustomWidgetAuthConfig {
const authSecrets = [...secrets, ...(authentication.redactValues ?? []).map((value) => ({ kind: "session", value }))];
const refresh = authentication.refreshAsync;
return {
...authentication,
type: "integration",
secrets: authSecrets,
refreshAsync:
refresh &&
(async () => {
try {
return toWidgetAuth(await refresh(), secrets);
} catch {
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: "Integration session could not be refreshed",
});
}
}),
};
}
@@ -57,28 +57,10 @@ export const customWidgetHttpSourceSchema = z.strictObject({
networkScope: z.enum(customJsxNetworkScopes),
auth: authSchema.default("none"),
});
export const customWidgetIntegrationKinds = [
"sonarr",
"radarr",
"lidarr",
"readarr",
"prowlarr",
"overseerr",
"jellyseerr",
"seerr",
"immich",
"slskd",
"homeAssistant",
"coolify",
"audiobookshelf",
"speedtestTracker",
"adGuardHome",
"nextcloud",
] as const;
export const customWidgetIntegrationSourceSchema = z.strictObject({
type: z.literal("integration"),
name: z.string().trim().min(1).max(128).optional(),
integrationKind: z.enum(customWidgetIntegrationKinds),
integrationKind: customWidgetIdentifierSchema,
integrationId: z.string().min(1).max(100).optional(),
baseUrl: z.never().optional(),
networkScope: z.never().optional(),
@@ -1,3 +1,6 @@
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
import { assertCustomWidgetPathScope, resolveSameOriginTarget } from "./network-policy";
type HeaderSetter = Pick<Headers, "set">;
export const AUTH_HANDLERS: Record<
@@ -43,3 +46,54 @@ export function applyAuth(
handler(headers, url, secretMap.apiKey, headerName ?? undefined);
}
}
export async function performAuthenticatedRequest(
input: CustomWidgetHttpRequest,
signal: AbortSignal,
perform: (input: CustomWidgetHttpRequest, signal: AbortSignal) => Promise<CustomWidgetHttpResponse>,
): Promise<CustomWidgetHttpResponse> {
const target = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(target, input.pathPrefix);
if (input.resolveConnectionAsync) {
const connection = await withinDeadline(input.resolveConnectionAsync, signal);
const base = new URL(connection.baseUrl);
target.protocol = base.protocol;
target.host = base.host;
input = { ...input, ...connection, targetUrl: target };
}
let response = await perform(input, signal);
if (!isExpired(input, response)) return response;
response.ok = false;
response.status = 401;
response.statusText = "Integration session expired";
if (!input.auth?.refreshAsync) return response;
const auth = await withinDeadline(input.auth.refreshAsync, signal);
// An action may have reached the service. Refresh for its next explicit invocation.
if (input.kind === "action") return response;
input = { ...input, auth };
response = await perform(input, signal);
if (isExpired(input, response)) {
response.ok = false;
response.status = 401;
response.statusText = "Integration session expired";
}
return response;
}
function isExpired(input: CustomWidgetHttpRequest, response: CustomWidgetHttpResponse) {
return input.auth && (response.status === 401 || input.auth.isExpired?.(response));
}
async function withinDeadline<T>(callback: () => Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted();
let abort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
abort = () => reject(signal.reason);
signal.addEventListener("abort", abort, { once: true });
});
try {
return await Promise.race([callback(), aborted]);
} finally {
if (abort) signal.removeEventListener("abort", abort);
}
}
+1 -1
View File
@@ -40,6 +40,6 @@ export {
} from "./request-manifest";
export type { CustomJsxRuntimeParams } from "./request-manifest";
export { redactResponseSecrets } from "./response-redaction";
export { redactResponseSecrets } from "./response";
export { assertCustomWidgetPathScope } from "./network-policy";
@@ -4,7 +4,7 @@ import { Headers, Response } from "undici";
import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-types";
import { applyAuth } from "./auth";
import { applyAuth, performAuthenticatedRequest } from "./auth";
import { CustomWidgetDomainError } from "./errors";
import {
assertSafeStaticHeaders,
@@ -16,7 +16,7 @@ import {
} from "./network-policy";
import { closeDispatcher } from "./request-dispatcher-lifecycle";
import { parseResponseBody } from "./response";
import { redactResponseSecrets } from "./response-redaction";
import { redactResponseSecrets } from "./response";
export {
assertSafeStaticHeaders,
@@ -52,7 +52,8 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
try {
return await performRequestWithinDeadline(input, controller.signal);
assertRequest(input);
return await performAuthenticatedRequest(input, controller.signal, performRequestWithinDeadline);
} catch (error) {
if (controller.signal.aborted) {
throw new CustomWidgetDomainError({
@@ -84,9 +85,12 @@ async function performRequestWithinDeadline(
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
let currentMethod = input.method;
let currentBody = input.body;
let currentBody = input.auth?.transformBody ? input.auth.transformBody(input.body) : input.body;
assertRequest({ ...input, body: currentBody });
const maxRedirects = input.kind === "query" ? MAX_QUERY_REDIRECTS : 0;
for (let redirects = 0; ; redirects += 1) {
input.auth?.transformUrl?.(currentUrl);
resolveSameOriginTarget(input.baseUrl, currentUrl);
if (input.pathPrefix !== undefined) assertCustomWidgetPathScope(currentUrl, input.pathPrefix);
const dispatcher = createPinnedAgent(
await resolveAndValidateHost(currentUrl.hostname, input.networkScope, { signal: deadlineSignal }),
@@ -114,15 +118,22 @@ async function performRequestWithinDeadline(
statusText: STATUS_CODES[responseData.statusCode] ?? "",
headers: normalizeResponseHeaders(responseData.headers),
});
result = {
kind: "response",
response: {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: redactResponseSecrets(await parseResponseBody(response), input.redactSecrets ?? []),
},
const parsed = {
ok: response.ok,
status: response.status,
statusText: response.statusText,
data: await parseResponseBody(response),
};
if (input.auth?.isExpired?.(parsed)) {
parsed.ok = false;
parsed.status = 401;
parsed.statusText = "Integration session expired";
}
parsed.data = redactResponseSecrets(parsed.data, [
...(input.redactSecrets ?? []),
...(input.auth?.secrets ?? []),
]);
result = { kind: "response", response: parsed };
} else {
await responseData.body.dump();
result = {
@@ -227,6 +238,8 @@ function buildHeaders(input: CustomWidgetHttpRequest, url: URL, body: string | u
if (input.auth) {
if (input.auth.type === "apiKeyHeader") assertSafeStaticHeaders({ [input.auth.headerName ?? "X-API-Key"]: "" });
applyAuth(headers, url, input.auth.type, input.auth.secrets, input.auth.headerName);
for (const [name, value] of Object.entries(input.auth.headers ?? {})) headers.set(name, value);
for (const [name, value] of Object.entries(input.auth.query ?? {})) url.searchParams.set(name, value);
}
return headers;
}
@@ -3,12 +3,22 @@ import type { ConnectionOptions } from "node:tls";
import type { CustomJsxNetworkScope, CustomWidgetMethod } from "../core";
export interface CustomWidgetAuthConfig {
baseUrl?: string;
type: string;
secrets: Array<{ kind: string; value: string }>;
headerName?: string | null;
headers?: Record<string, string>;
query?: Record<string, string>;
transformUrl?(url: URL): void;
transformBody?(body: string | undefined): string;
isExpired?(response: CustomWidgetHttpResponse): boolean;
refreshAsync?(): Promise<CustomWidgetAuthConfig>;
}
export interface CustomWidgetHttpRequest {
resolveConnectionAsync?(): Promise<
Pick<CustomWidgetHttpRequest, "baseUrl" | "auth" | "tls" | "pathPrefix" | "redactSecrets">
>;
tls?: Pick<ConnectionOptions, "ca" | "checkServerIdentity">;
pathPrefix?: string;
redactSecrets?: CustomWidgetAuthConfig["secrets"];
@@ -1,37 +0,0 @@
import { Buffer } from "node:buffer";
export function redactResponseSecrets(data: unknown, secrets: Array<{ kind: string; value: string }>): unknown {
if (secrets.length === 0) return data;
const values = secrets.map(({ value }) => value).filter(Boolean);
const username = secrets.find(({ kind }) => kind === "username")?.value;
const password = secrets.find(({ kind }) => kind === "password")?.value;
if (username && password) values.push(`${username}:${password}`);
const sensitive = [
...new Set(
values.flatMap((value) => [
value,
JSON.stringify(value).slice(1, -1),
encodeURIComponent(value),
Buffer.from(value).toString("base64"),
]),
),
].toSorted((a, b) => b.length - a.length);
const redact = (value: string) => {
for (const secret of sensitive) value = value.replaceAll(secret, "[REDACTED]");
return value;
};
const visit = (value: unknown): unknown => {
if (typeof value === "string") return redact(value);
if (
(typeof value === "number" || typeof value === "boolean" || value === null) &&
redact(String(value)) !== String(value)
)
return "[REDACTED]";
if (Array.isArray(value)) return value.map(visit);
if (value !== null && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [redact(key), visit(child)]));
}
return value;
};
return visit(data);
}
@@ -1,3 +1,4 @@
import { Buffer } from "node:buffer";
import type { Response } from "undici";
import { CustomWidgetDomainError } from "./errors";
@@ -63,3 +64,39 @@ export async function parseResponseBody(response: Response): Promise<unknown> {
const tooLarge = () =>
new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Response exceeds the 1 MiB limit" });
export function redactResponseSecrets(data: unknown, secrets: Array<{ kind: string; value: string }>): unknown {
if (secrets.length === 0) return data;
const values = secrets.map(({ value }) => value).filter(Boolean);
const username = secrets.find(({ kind }) => kind === "username")?.value;
const password = secrets.find(({ kind }) => kind === "password")?.value;
if (username && password) values.push(`${username}:${password}`);
const sensitive = [
...new Set(
values.flatMap((value) => [
value,
JSON.stringify(value).slice(1, -1),
encodeURIComponent(value),
Buffer.from(value).toString("base64"),
]),
),
].toSorted((a, b) => b.length - a.length);
const redact = (value: string) => {
for (const secret of sensitive) value = value.replaceAll(secret, "[REDACTED]");
return value;
};
const visit = (value: unknown): unknown => {
if (typeof value === "string") return redact(value);
if (
(typeof value === "number" || typeof value === "boolean" || value === null) &&
redact(String(value)) !== String(value)
)
return "[REDACTED]";
if (Array.isArray(value)) return value.map(visit);
if (value !== null && typeof value === "object") {
return Object.fromEntries(Object.entries(value).map(([key, child]) => [redact(key), visit(child)]));
}
return value;
};
return visit(data);
}
+5
View File
@@ -725,3 +725,8 @@ export const integrationCategories = [
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
/** Integrations whose configured connection can serve HTTP requests. */
export function isHttpIntegrationKind(kind: string): kind is Exclude<IntegrationKind, "truenas" | "mock"> {
return Object.hasOwn(integrationDefs, kind) && kind !== "truenas" && kind !== "mock";
}
-1
View File
@@ -12,7 +12,6 @@
}
},
"exports": {
"./http-auth": "./src/http-auth.ts",
".": "./index.ts",
"./anchor": "./src/anchor/anchor-types.ts",
"./downloads": "./src/interfaces/downloads/download-client-items.ts",
+288 -102
View File
@@ -2,117 +2,295 @@ import type { IntegrationKind } from "@homarr/definitions";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import {
withHttpAuthentication,
apiKeyAuth,
bearerAuth,
basicAuth,
headerAuth,
queryAuth,
noAuth,
optionalAuth,
alternativeAuth,
rpcAuth,
feedAuth,
secret,
clientAuthentication,
} from "../http-auth";
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { Integration, IntegrationInput } from "./integration";
const logger = createLogger({ module: "integrationFactory" });
// Keep each import path explicit so Node and Turbopack can discover every lazy integration chunk.
const integrationCreators = {
anchor: async (input: IntegrationInput) =>
new (await import("../anchor/anchor-integration")).AnchorIntegration(input),
piHole: async (input: IntegrationInput) =>
(await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync(input),
adGuardHome: async (input: IntegrationInput) =>
new (await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration(input),
technitiumDns: async (input: IntegrationInput) =>
(await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync(input),
homeAssistant: async (input: IntegrationInput) =>
new (await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration(input),
jellyfin: async (input: IntegrationInput) =>
new (await import("../jellyfin/jellyfin-integration")).JellyfinIntegration(input),
plex: async (input: IntegrationInput) => new (await import("../plex/plex-integration")).PlexIntegration(input),
sonarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration(input),
radarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration(input),
sabNzbd: async (input: IntegrationInput) =>
new (await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration(input),
nzbGet: async (input: IntegrationInput) =>
new (await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration(input),
qBittorrent: async (input: IntegrationInput) =>
new (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration(input),
deluge: async (input: IntegrationInput) =>
new (await import("../download-client/deluge/deluge-integration")).DelugeIntegration(input),
transmission: async (input: IntegrationInput) =>
new (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration(input),
slskd: async (input: IntegrationInput) =>
new (await import("../download-client/slskd/slskd-integration")).SlskdIntegration(input),
aria2: async (input: IntegrationInput) =>
new (await import("../download-client/aria2/aria2-integration")).Aria2Integration(input),
jellyseerr: async (input: IntegrationInput) =>
new (await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration(input),
seerr: async (input: IntegrationInput) => new (await import("../seerr/seerr-integration")).SeerrIntegration(input),
overseerr: async (input: IntegrationInput) =>
new (await import("../overseerr/overseerr-integration")).OverseerrIntegration(input),
prowlarr: async (input: IntegrationInput) =>
new (await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration(input),
openmediavault: async (input: IntegrationInput) =>
new (await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration(input),
lidarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration(input),
readarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration(input),
dashDot: async (input: IntegrationInput) =>
new (await import("../dashdot/dashdot-integration")).DashDotIntegration(input),
tdarr: async (input: IntegrationInput) =>
new (await import("../media-transcoding/tdarr-integration")).TdarrIntegration(input),
proxmox: async (input: IntegrationInput) =>
new (await import("../proxmox/proxmox-integration")).ProxmoxIntegration(input),
emby: async (input: IntegrationInput) => new (await import("../emby/emby-integration")).EmbyIntegration(input),
nextcloud: async (input: IntegrationInput) =>
new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input),
unifiController: async (input: IntegrationInput) =>
new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input),
opnsense: async (input: IntegrationInput) =>
new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input),
ical: async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input),
ntfy: async (input: IntegrationInput) => new (await import("../ntfy/ntfy-integration")).NTFYIntegration(input),
gotify: async (input: IntegrationInput) =>
new (await import("../gotify/gotify-integration")).GotifyIntegration(input),
anchor: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../anchor/anchor-integration")).AnchorIntegration(input),
bearerAuth,
),
piHole: withHttpAuthentication(
async (input: IntegrationInput) =>
(await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync(input),
clientAuthentication,
),
adGuardHome: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration(input),
basicAuth(),
),
technitiumDns: withHttpAuthentication(
async (input: IntegrationInput) =>
(await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync(input),
clientAuthentication,
),
homeAssistant: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration(input),
bearerAuth,
),
jellyfin: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../jellyfin/jellyfin-integration")).JellyfinIntegration(input),
clientAuthentication,
),
plex: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../plex/plex-integration")).PlexIntegration(input),
headerAuth("X-Plex-Token"),
),
sonarr: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration(input),
apiKeyAuth,
),
radarr: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration(input),
apiKeyAuth,
),
sabNzbd: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration(input),
queryAuth("apikey"),
),
nzbGet: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration(input),
basicAuth(),
),
qBittorrent: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration(input),
clientAuthentication,
),
deluge: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../download-client/deluge/deluge-integration")).DelugeIntegration(input),
clientAuthentication,
),
transmission: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration(input),
clientAuthentication,
),
slskd: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../download-client/slskd/slskd-integration")).SlskdIntegration(input),
apiKeyAuth,
),
aria2: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../download-client/aria2/aria2-integration")).Aria2Integration(input),
optionalAuth(rpcAuth),
),
jellyseerr: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration(input),
apiKeyAuth,
),
seerr: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../seerr/seerr-integration")).SeerrIntegration(input),
apiKeyAuth,
),
overseerr: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../overseerr/overseerr-integration")).OverseerrIntegration(input),
apiKeyAuth,
),
prowlarr: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration(input),
apiKeyAuth,
),
openmediavault: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration(input),
clientAuthentication,
),
lidarr: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration(input),
apiKeyAuth,
),
readarr: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration(input),
apiKeyAuth,
),
dashDot: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../dashdot/dashdot-integration")).DashDotIntegration(input),
noAuth,
),
tdarr: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../media-transcoding/tdarr-integration")).TdarrIntegration(input),
optionalAuth(apiKeyAuth),
),
proxmox: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../proxmox/proxmox-integration")).ProxmoxIntegration(input),
(input) => ({
headers: {
Authorization: `PVEAPIToken=${secret(input, "username")}@${secret(input, "realm")}!${secret(input, "tokenId")}=${secret(input, "apiKey")}`,
},
}),
),
emby: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../emby/emby-integration")).EmbyIntegration(input),
headerAuth("X-Emby-Token"),
),
nextcloud: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input),
basicAuth("username", "password", { "OCS-APIRequest": "true" }),
),
unifiController: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input),
clientAuthentication,
),
opnsense: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input),
basicAuth("opnsenseApiKey", "opnsenseApiSecret"),
),
ical: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input),
feedAuth,
),
ntfy: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../ntfy/ntfy-integration")).NTFYIntegration(input),
optionalAuth(bearerAuth),
),
gotify: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../gotify/gotify-integration")).GotifyIntegration(input),
basicAuth(),
),
mock: async (input: IntegrationInput) => new (await import("../mock/mock-integration")).MockIntegration(input),
truenas: async (input: IntegrationInput) =>
new (await import("../truenas/truenas-integration")).TrueNasIntegration(input),
synology: async (input: IntegrationInput) =>
new (await import("../synology/synology-integration")).SynologyIntegration(input),
unraid: async (input: IntegrationInput) =>
new (await import("../unraid/unraid-integration")).UnraidIntegration(input),
coolify: async (input: IntegrationInput) =>
new (await import("../coolify/coolify-integration")).CoolifyIntegration(input),
tracearr: async (input: IntegrationInput) =>
new (await import("../tracearr/tracearr-integration")).TracearrIntegration(input),
glances: async (input: IntegrationInput) =>
new (await import("../glances/glances-integration")).GlancesIntegration(input),
immich: async (input: IntegrationInput) =>
new (await import("../immich/immich-integration")).ImmichIntegration(input),
paperlessNgx: async (input: IntegrationInput) =>
new (await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration(input),
patchmon: async (input: IntegrationInput) =>
new (await import("../patchmon/patchmon-integration")).PatchMonIntegration(input),
speedtestTracker: async (input: IntegrationInput) =>
new (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration(input),
audiobookshelf: async (input: IntegrationInput) =>
new (await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration(input),
navidrome: async (input: IntegrationInput) =>
new (await import("../navidrome/navidrome-integration")).NavidromeIntegration(input),
umami: async (input: IntegrationInput) => new (await import("../umami/umami-integration")).UmamiIntegration(input),
gluetun: async (input: IntegrationInput) =>
new (await import("../gluetun/gluetun-integration")).GluetunIntegration(input),
archiveTeamWarrior: async (input: IntegrationInput) =>
new (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration(input),
uptimeKuma: async (input: IntegrationInput) =>
new (await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration(input),
peaNut: async (input: IntegrationInput) =>
new (await import("../peanut/peanut-integration")).PeaNutIntegration(input),
beszel: async (input: IntegrationInput) =>
new (await import("../beszel/beszel-integration")).BeszelIntegration(input),
bazarr: async (input: IntegrationInput) =>
new (await import("../bazarr/bazarr-integration")).BazarrIntegration(input),
traefik: async (input: IntegrationInput) =>
new (await import("../traefik/traefik-integration")).TraefikIntegration(input),
wud: async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input),
llamacpp: async (input: IntegrationInput) =>
new (await import("../llama-cpp/llamacpp-integration")).LlamacppIntegration(input),
} satisfies Record<IntegrationKind, (input: IntegrationInput) => Promise<Integration>>;
synology: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../synology/synology-integration")).SynologyIntegration(input),
clientAuthentication,
),
unraid: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../unraid/unraid-integration")).UnraidIntegration(input),
apiKeyAuth,
),
coolify: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../coolify/coolify-integration")).CoolifyIntegration(input),
bearerAuth,
),
tracearr: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../tracearr/tracearr-integration")).TracearrIntegration(input),
bearerAuth,
),
glances: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../glances/glances-integration")).GlancesIntegration(input),
noAuth,
),
immich: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../immich/immich-integration")).ImmichIntegration(input),
apiKeyAuth,
),
paperlessNgx: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration(input),
headerAuth("Authorization", "Token "),
),
patchmon: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../patchmon/patchmon-integration")).PatchMonIntegration(input),
basicAuth("patchmonApiKey", "patchmonApiSecret"),
),
speedtestTracker: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration(input),
bearerAuth,
),
audiobookshelf: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration(input),
bearerAuth,
),
navidrome: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../navidrome/navidrome-integration")).NavidromeIntegration(input),
clientAuthentication,
),
umami: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../umami/umami-integration")).UmamiIntegration(input),
clientAuthentication,
),
gluetun: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../gluetun/gluetun-integration")).GluetunIntegration(input),
alternativeAuth(apiKeyAuth),
),
archiveTeamWarrior: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration(
input,
),
optionalAuth(basicAuth(), ["username", "password"]),
),
uptimeKuma: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration(input),
noAuth,
),
peaNut: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../peanut/peanut-integration")).PeaNutIntegration(input),
optionalAuth(basicAuth(), ["username", "password"]),
),
beszel: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../beszel/beszel-integration")).BeszelIntegration(input),
clientAuthentication,
),
bazarr: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../bazarr/bazarr-integration")).BazarrIntegration(input),
apiKeyAuth,
),
traefik: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../traefik/traefik-integration")).TraefikIntegration(input),
clientAuthentication,
),
wud: withHttpAuthentication(
async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input),
optionalAuth(basicAuth(), ["username", "password"]),
),
llamacpp: withHttpAuthentication(
async (input: IntegrationInput) =>
new (await import("../llama-cpp/llamacpp-integration")).LlamacppIntegration(input),
noAuth,
),
} satisfies {
[Kind in IntegrationKind]: ((input: IntegrationInput) => Promise<Integration>) &
(Kind extends "truenas" | "mock"
? unknown
: {
authenticate(input: IntegrationInput): IntegrationHttpAuthentication | Promise<IntegrationHttpAuthentication>;
});
};
type IntegrationCreators = typeof integrationCreators;
type IntegrationInstanceOfKind<TKind extends keyof IntegrationCreators> = Awaited<
@@ -162,3 +340,11 @@ export async function createIntegrationAsync(
throw error;
}
}
export async function getIntegrationHttpAuthenticationAsync(
integration: IntegrationInput & { kind: IntegrationKind },
): Promise<IntegrationHttpAuthentication> {
const creator = integrationCreators[integration.kind];
if (!("authenticate" in creator)) throw new Error("This integration does not use HTTP");
return creator.authenticate(integration);
}
@@ -125,6 +125,12 @@ export const isSessionExpired = (session: BeszelSession, now = Date.now()) =>
session.expiresAt !== undefined && session.expiresAt - sessionExpiryLeewayMs <= now;
export class BeszelIntegration extends Integration {
public async getHttpAuthenticationAsync(refresh = false) {
if (refresh) await this.sessionStore.clearAsync();
const session = await this.authenticateAsync();
return { headers: { Authorization: session.token } };
}
private readonly sessionStore: SessionStore<BeszelSession>;
constructor(integration: IntegrationInput) {
@@ -17,6 +17,15 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class DelugeIntegration extends Integration implements IDownloadClientIntegration {
public async getHttpAuthenticationAsync() {
const client = await this.getClientAsync();
await client.login();
if (!(await client.connected())) await client.connect();
const cookie = client.exportState().auth.cookieHeader;
if (!cookie) throw new Error("Integration authentication did not return a session");
return { headers: { Cookie: cookie } };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher);
const isSuccess = await client.login();
@@ -1,3 +1,4 @@
import type { IntegrationHttpAuthentication } from "../../http-auth";
import { QBittorrent } from "@ctrl/qbittorrent";
import dayjs from "dayjs";
import type { Dispatcher } from "undici";
@@ -17,6 +18,17 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class QBitTorrentIntegration extends Integration implements IDownloadClientIntegration {
public async getHttpAuthenticationAsync(): Promise<IntegrationHttpAuthentication> {
if (this.hasSecretValue("apiKey")) return { headers: { Authorization: `Bearer ${this.getSecretValue("apiKey")}` } };
const client = await this.getClientAsync();
const auth = client.exportState().auth;
if (!auth) return {};
return {
headers: { Cookie: `${auth.cookieName ?? "SID"}=${auth.sid}` },
isExpired: ({ status }) => status === 403,
};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher);
const isSuccess = await client.login();
@@ -16,6 +16,20 @@ import type { DownloadClientStatus } from "../../interfaces/downloads/download-c
@HandleIntegrationErrors([integrationOFetchHttpErrorHandler])
export class TransmissionIntegration extends Integration implements IDownloadClientIntegration {
public async getHttpAuthenticationAsync() {
const client = await this.getClientAsync();
await client.getSession();
const session = client.exportState().auth?.sessionId;
if (!session) throw new Error("Integration authentication did not return a session");
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
"base64",
);
return {
headers: { Authorization: `Basic ${credentials}`, "X-Transmission-Session-Id": session },
isExpired: (response: { status: number }) => response.status === 409,
};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const client = await this.getClientAsync(input.dispatcher);
await client.getSession();
+3 -1
View File
@@ -1 +1,3 @@
export { createIntegrationAsync } from "./base/creator";
export { createIntegrationAsync, getIntegrationHttpAuthenticationAsync } from "./base/creator";
export type { IntegrationHttpAuthentication } from "./http-auth";
+142 -50
View File
@@ -1,56 +1,148 @@
import type { IntegrationKind } from "@homarr/definitions";
import { Buffer } from "node:buffer";
export interface IntegrationHttpAuth {
type: "apiKeyHeader" | "bearer" | "basic";
secrets: Array<{ kind: string; value: string }>;
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationInput } from "./base/integration";
export interface IntegrationHttpAuthentication {
headers?: Record<string, string>;
redactValues?: string[];
query?: Record<string, string>;
baseUrl?: string;
transformUrl?(url: URL): void;
transformBody?(body: string | undefined): string;
isExpired?(response: { status: number; data: unknown }): boolean;
refreshAsync?(): Promise<IntegrationHttpAuthentication>;
}
export class IntegrationHttpAuthError extends Error {
readonly code: "BAD_REQUEST" | "PRECONDITION_FAILED";
constructor(input: { code: "BAD_REQUEST" | "PRECONDITION_FAILED"; message: string }) {
super(input.message);
this.code = input.code;
export function secret(input: IntegrationInput, kind: IntegrationSecretKind) {
const value = input.decryptedSecrets.find((entry) => entry.kind === kind)?.value;
if (!value) throw new Error(`Integration credential ${kind} is missing`);
return value;
}
export const headerAuth =
(name: string, prefix = "", kind: IntegrationSecretKind = "apiKey") =>
(input: IntegrationInput): IntegrationHttpAuthentication => ({
headers: { [name]: `${prefix}${secret(input, kind)}` },
});
export const bearerAuth = headerAuth("Authorization", "Bearer ");
export const apiKeyAuth = headerAuth("X-API-Key");
export const basicAuth =
(
username: IntegrationSecretKind = "username",
password: IntegrationSecretKind = "password",
headers: Record<string, string> = {},
) =>
(input: IntegrationInput): IntegrationHttpAuthentication => {
const credentials = Buffer.from(`${secret(input, username)}:${secret(input, password)}`).toString("base64");
return { headers: { ...headers, Authorization: `Basic ${credentials}` }, redactValues: [credentials] };
};
export const queryAuth =
(name: string, kind: IntegrationSecretKind = "apiKey") =>
(input: IntegrationInput): IntegrationHttpAuthentication => ({ query: { [name]: secret(input, kind) } });
export const noAuth = (): IntegrationHttpAuthentication => ({});
export const optionalAuth =
(
resolve: (input: IntegrationInput) => IntegrationHttpAuthentication,
required: IntegrationSecretKind[] = ["apiKey"],
) =>
(input: IntegrationInput) => {
if (
!required.every((requiredKind) =>
input.decryptedSecrets.some(({ kind, value }) => kind === requiredKind && value),
)
)
return noAuth();
return resolve(input);
};
export const alternativeAuth =
(
apiKey: (input: IntegrationInput) => IntegrationHttpAuthentication,
fallback = optionalAuth(basicAuth(), ["username", "password"]),
) =>
(input: IntegrationInput) => {
if (input.decryptedSecrets.some(({ kind, value }) => kind === "apiKey" && value)) return apiKey(input);
return fallback(input);
};
export const rpcAuth = (input: IntegrationInput): IntegrationHttpAuthentication => ({
transformBody(body) {
const authenticate = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(authenticate);
if (!value || typeof value !== "object") throw new Error("Expected a JSON-RPC request body");
const request = value as { method?: string; methodName?: string; params?: unknown[] };
const method = request.method ?? request.methodName;
const params = request.params ?? [];
if (!Array.isArray(params)) throw new Error("JSON-RPC params must be an array");
if (method === "system.multicall") return { ...request, params: [authenticate(params[0])] };
if (method === "system.listMethods" || method === "system.listNotifications") return request;
if (typeof params[0] === "string" && params[0].startsWith("token:")) params.shift();
return { ...request, params: [`token:${secret(input, "apiKey")}`, ...params] };
};
return JSON.stringify(authenticate(JSON.parse(body ?? "null")));
},
});
export function withHttpAuthentication<T>(
create: (input: IntegrationInput) => Promise<T>,
authenticate: (
input: IntegrationInput,
create: () => Promise<T>,
) => IntegrationHttpAuthentication | Promise<IntegrationHttpAuthentication>,
) {
return Object.assign(create, {
authenticate: (input: IntegrationInput) => withAuthTimeout(() => authenticate(input, () => create(input))),
});
}
export const clientAuthentication = async <
T extends { getHttpAuthenticationAsync(refresh?: boolean): Promise<IntegrationHttpAuthentication> },
>(
_input: IntegrationInput,
create: () => Promise<T>,
) => {
const client = await create();
const resolve = async (refresh = false): Promise<IntegrationHttpAuthentication> => {
const auth = await client.getHttpAuthenticationAsync(refresh);
const values = [...Object.values(auth.headers ?? {}), ...Object.values(auth.query ?? {})];
for (const [name, value] of Object.entries(auth.headers ?? {})) {
if (name.toLowerCase() === "cookie") {
for (const cookie of value.split(";")) values.push(cookie.slice(cookie.indexOf("=") + 1).trim());
}
if (name.toLowerCase() === "authorization") values.push(value.slice(value.indexOf(" ") + 1));
}
return {
...auth,
redactValues: auth.redactValues ?? values,
refreshAsync: () => withAuthTimeout(() => resolve(true)),
};
};
return resolve();
};
async function withAuthTimeout<T>(resolve: () => T | Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
Promise.resolve().then(resolve),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error("Integration authentication timed out")), 10_000);
}),
]);
} finally {
clearTimeout(timer);
}
}
// Match the existing adapters; never guess authentication from secret names alone.
export const integrationHttpAuth = {
sonarr: "apiKeyHeader",
radarr: "apiKeyHeader",
lidarr: "apiKeyHeader",
readarr: "apiKeyHeader",
prowlarr: "apiKeyHeader",
overseerr: "apiKeyHeader",
jellyseerr: "apiKeyHeader",
seerr: "apiKeyHeader",
immich: "apiKeyHeader",
slskd: "apiKeyHeader",
homeAssistant: "bearer",
coolify: "bearer",
audiobookshelf: "bearer",
speedtestTracker: "bearer",
adGuardHome: "basic",
nextcloud: "basic",
} as const satisfies Partial<Record<IntegrationKind, string>>;
export function resolveIntegrationHttpAuth(
kind: IntegrationKind,
secrets: IntegrationHttpAuth["secrets"],
): IntegrationHttpAuth {
if (!(kind in integrationHttpAuth)) {
throw new IntegrationHttpAuthError({
code: "BAD_REQUEST",
message: "This integration's authentication is not supported by HTTP requests",
});
}
const type = integrationHttpAuth[kind as keyof typeof integrationHttpAuth];
let required = ["apiKey"];
if (type === "basic") required = ["username", "password"];
if (required.some((key) => !secrets.some((secret) => secret.kind === key && secret.value))) {
throw new IntegrationHttpAuthError({
code: "PRECONDITION_FAILED",
message: "Integration credentials are incomplete",
});
}
return { type, secrets };
}
export const feedAuth = (input: IntegrationInput): IntegrationHttpAuthentication => {
const baseUrl = secret(input, "url");
const base = new URL(baseUrl);
return {
baseUrl,
redactValues: [...base.searchParams.values()],
transformUrl(url) {
// The stored feed URL is a complete endpoint, so the root request uses it exactly.
if (url.pathname === `${base.pathname}/`) url.pathname = base.pathname;
for (const [name, value] of base.searchParams) url.searchParams.set(name, value);
},
};
};
@@ -60,6 +60,11 @@ export function parseLocation(remoteEndPoint: string | null | undefined): "lan"
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getHttpAuthenticationAsync() {
const api = await this.getApiAsync();
return { headers: { "X-Emby-Token": api.accessToken } };
}
private readonly jellyfin: Jellyfin = new Jellyfin({
clientInfo: {
name: "Homarr",
@@ -25,6 +25,10 @@ const asArray = <TValue>(value: TValue | TValue[] | undefined): TValue[] =>
[value].flat().filter((item): item is TValue => item !== undefined);
export class NavidromeIntegration extends Integration implements IMediaServerIntegration {
public async getHttpAuthenticationAsync() {
return { query: this.getAuthParams(), redactValues: [] };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const url = this.url("/rest/ping.view", this.getAuthParams());
const response = await input.fetchAsync(url);
@@ -1,3 +1,4 @@
import type { IntegrationHttpAuthentication } from "../http-auth";
import type { Headers, HeadersInit, fetch as undiciFetch, Response as UndiciResponse } from "undici";
import { ResponseError } from "@homarr/common/server";
@@ -20,6 +21,18 @@ type SessionStoreValue =
| { type: "cookie"; loginToken: string; sessionId: string };
export class OpenMediaVaultIntegration extends Integration implements ISystemHealthMonitoringIntegration {
public async getHttpAuthenticationAsync(refresh = false): Promise<IntegrationHttpAuthentication> {
if (refresh) await this.sessionStore.clearAsync();
let session = await this.sessionStore.getAsync();
if (!session) {
session = await this.getSessionAsync();
await this.sessionStore.setAsync(session);
}
if (session.type === "cookie")
return { headers: { Cookie: `${session.loginToken.split(";")[0]}; ${session.sessionId.split(";")[0]}` } };
return { headers: { "X-OPENMEDIAVAULT-SESSIONID": session.sessionId } };
}
private readonly sessionStore: SessionStore<SessionStoreValue>;
constructor(integration: IntegrationInput) {
@@ -11,6 +11,12 @@ import type { DnsHoleSummary } from "../../interfaces/dns-hole-summary/dns-hole-
import { summaryResponseSchema } from "./pi-hole-schemas-v5";
export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIntegration {
public async getHttpAuthenticationAsync() {
const query: Record<string, string> = {};
if (this.hasSecretValue("apiKey")) query.auth = this.getSecretValue("apiKey");
return { query };
}
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(this.url("/admin/api.php?summaryRaw", { auth: apiKey }));
@@ -17,6 +17,19 @@ import { dnsBlockingGetSchema, sessionResponseSchema, statsSummaryGetSchema } fr
const logger = createLogger({ module: "piHoleIntegration", version: "v6" });
export class PiHoleIntegrationV6 extends Integration implements DnsHoleSummaryIntegration {
public async getHttpAuthenticationAsync(refresh = false) {
if (!this.hasSecretValue("apiKey")) return { headers: {} as Record<string, string> };
if (refresh) await this.sessionStore.clearAsync();
let session = await this.sessionStore.getAsync();
if (!session) {
session = { sid: await this.getSessionAsync() };
await this.sessionStore.setAsync(session);
}
const headers: Record<string, string> = {};
if (session.sid) headers.sid = session.sid;
return { headers };
}
private readonly sessionStore: SessionStore<{ sid: string | null }>;
constructor(integration: IntegrationInput) {
@@ -51,6 +51,25 @@ type SynologyClientOptions = {
const apiDefinitionCache = new Map<string, ApiDefinition>();
export class SynologyClient {
public async getHttpAuthenticationAsync(refresh = false) {
if (refresh) await this.sessionStore.clearAsync();
let session = await this.sessionStore.getAsync();
if (!session) {
session = await this.loginAsync();
await this.sessionStore.setAsync(session);
}
return {
headers: { Cookie: session.cookieHeader },
isExpired(response: { status: number; data: unknown }) {
const payload = synologyEnvelopeSchema.safeParse(response.data);
return (
response.status === 401 ||
(payload.success && payload.data.success === false && SESSION_ERROR_CODES.has(payload.data.error?.code ?? 0))
);
},
};
}
private readonly integrationId: string;
private readonly baseUrl: string;
private readonly username: string;
@@ -22,6 +22,10 @@ type StoredSession = {
@HandleIntegrationErrors([])
export class SynologyIntegration extends Integration implements ISystemHealthMonitoringIntegration {
public async getHttpAuthenticationAsync(refresh = false) {
return this.getClient().getHttpAuthenticationAsync(refresh);
}
private readonly sessionStore = createSessionStore<StoredSession>(this.integration);
private client?: SynologyClient;
@@ -26,6 +26,26 @@ import type { StoredSession, TechnitiumVersion } from "./technitium-types";
const logger = createLogger({ module: "technitiumDnsIntegration" });
export class TechnitiumDnsIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getHttpAuthenticationAsync(refresh = false) {
if (refresh) await this.sessionStore.clearAsync();
const token = await this.acquireTokenAsync();
const headers: Record<string, string> = {};
const query: Record<string, string> = {};
if (token) {
if (this.version === "v15") headers.Authorization = `Bearer ${token}`;
else query.token = token;
}
return {
headers,
query,
isExpired: (response: { data: unknown }) =>
response.data !== null &&
typeof response.data === "object" &&
"status" in response.data &&
response.data.status === "invalid-token",
};
}
// Mutable: updated when a login response or session cache reveals the server's auth version.
private version: TechnitiumVersion;
private readonly sessionStore: SessionStore<StoredSession>;
@@ -29,6 +29,10 @@ type ResourcePath =
const MAX_RESOURCE_DETAILS = 200;
export class TraefikIntegration extends Integration {
public async getHttpAuthenticationAsync() {
return { headers: this.getAuthHeaders() };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() });
@@ -47,6 +47,10 @@ const extractDataArray = (json: unknown): unknown[] => {
};
export class UmamiIntegration extends Integration {
public async getHttpAuthenticationAsync() {
return { headers: await this.getAuthHeadersAsync() };
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url("/websites");
@@ -21,6 +21,14 @@ import type { HealthSubsystem } from "./unifi-controller-types";
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class UnifiControllerIntegration extends Integration implements NetworkControllerSummaryIntegration {
private httpAuthentication?: () => Promise<{ headers: Record<string, string>; baseUrl: string }>;
public async getHttpAuthenticationAsync() {
await this.createControllerClientAsync();
if (!this.httpAuthentication) throw new Error("Integration authentication did not return a session");
return this.httpAuthentication();
}
public async getNetworkSummaryAsync(): Promise<NetworkControllerSummary> {
const client = await this.createControllerClientAsync();
const stats = await client.getSitesStats();
@@ -71,13 +79,17 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo
// use 8443. If no port was provided, try both without hiding authentication errors.
const ports = url.port ? [Number(url.port)] : [443, 8443];
const createClientForPortAsync = async (port: number) => {
let readAuthentication: typeof this.httpAuthentication;
const baseUrl = new URL(this.integration.url);
baseUrl.protocol = "https:";
baseUrl.port = String(port);
const client = new Unifi.Controller({
host: url.hostname,
port,
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
createAxiosInstance({ cookies }) {
return axios.create({
const instance = axios.create({
adapter: "http",
httpAgent: new HttpCookieAgent({ cookies }),
httpsAgent: new HttpsCookieAgent({
@@ -86,10 +98,18 @@ export class UnifiControllerIntegration extends Integration implements NetworkCo
...certificateOptions,
}),
});
readAuthentication = async () => {
const headers: Record<string, string> = { Cookie: await cookies.jar.getCookieString(baseUrl.href) };
const csrf = instance.defaults.headers.common["x-csrf-token"];
if (typeof csrf === "string") headers["x-csrf-token"] = csrf;
return { headers, baseUrl: baseUrl.href };
};
return instance;
},
});
await client.login(this.getSecretValue("username"), this.getSecretValue("password"), null);
this.httpAuthentication = readAuthentication;
return client;
};