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

@@ -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",
});
}
}),
};
}