feat: add per-widget secret storage for release provider authentication

Adds encrypted token storage per release widget so users can authenticate
with GitHub, GitLab, Docker Hub, etc. without needing full integration setup.

- New `widget_secret` table (all 3 DB drivers) keyed by (itemId, kind)
- tRPC router with setSecret/deleteSecret/getConfiguredKinds (board modify access required)
- Releases router injects decrypted tokens into provider requests (board view access required)
- Tokens only used for repos matching widget's stored configuration (prevents proxy abuse)
- Provider auth: GitHub (Octokit auth), GitLab (PRIVATE-TOKEN), Docker Hub (Bearer),
  GHCR (registry token), npm (Bearer), Codeberg (token header)
- UI: "Provider Tokens" section in widget editor with PasswordInput per provider
This commit is contained in:
Thomas Camlong committed 2026-06-27 10:57:13 +02:00
1 parent d8a7477039
commit 77884a3179
14 files changed
+358 -20

No files matched your search

+1
View File
@@ -39,4 +39,5 @@ export const widgetRouter = createTRPCRouter({
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
});
+36 -1
View File
@@ -1,10 +1,15 @@
import SuperJSON from "superjson";
import { escapeForRegEx } from "@tiptap/react";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { eq } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions";
import { releasesRequestHandler } from "@homarr/request-handler/releases";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
@@ -26,6 +31,7 @@ export const releasesRouter = createTRPCRouter({
getLatest: publicProcedure
.input(
z.object({
itemId: z.string().optional(),
repositories: z.array(
z.object({
id: z.string(),
@@ -37,9 +43,37 @@ export const releasesRouter = createTRPCRouter({
),
}),
)
.query(async ({ input }) => {
.query(async ({ ctx, input }) => {
const tokensByProvider = new Map<string, string>();
let allowedRepoIds: Set<string> | null = null;
if (input.itemId) {
const item = await ctx.db.query.items.findFirst({ where: eq(items.id, input.itemId) });
if (item && item.kind === "releases") {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
const repos = options.repositories as Array<{ id?: string }> | undefined;
if (repos) {
allowedRepoIds = new Set(repos.map((r) => r.id).filter((id): id is string => Boolean(id)));
}
const secrets = await ctx.db.query.widgetSecrets.findMany({
where: eq(widgetSecrets.itemId, input.itemId),
});
for (const secret of secrets) {
try {
tokensByProvider.set(secret.kind, decryptSecret(secret.value));
} catch {
// Skip corrupt secrets
}
}
}
}
return await Promise.all(
input.repositories.map(async (repository) => {
const useToken = allowedRepoIds === null || allowedRepoIds.has(repository.id);
const response = await releasesRequestHandler
.handler({
id: repository.id,
@@ -47,6 +81,7 @@ export const releasesRouter = createTRPCRouter({
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
providerUrl: repository.providerUrl,
token: useToken ? tokensByProvider.get(repository.provider) : undefined,
})
.getCachedOrUpdatedDataAsync({
forceUpdate: false,
@@ -0,0 +1,63 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { encryptSecret } from "@homarr/common/server";
import { and, eq } from "@homarr/db";
import { boards, items, widgetSecrets } from "@homarr/db/schema";
import { releaseProviderKinds } from "@homarr/definitions";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const secretKindSchema = z.enum(releaseProviderKinds);
const resolveItemWithBoardAccess = async (
ctx: { db: any; session: any },
itemId: string,
) => {
const item = await ctx.db.query.items.findFirst({ where: eq(items.id, itemId) });
if (!item || item.kind !== "releases") throw new TRPCError({ code: "NOT_FOUND" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "modify");
return item;
};
export const widgetSecretsRouter = createTRPCRouter({
setSecret: protectedProcedure
.input(z.object({ itemId: z.string(), kind: secretKindSchema, value: z.string().min(1).max(4096) }))
.mutation(async ({ ctx, input }) => {
await resolveItemWithBoardAccess(ctx, input.itemId);
await ctx.db
.delete(widgetSecrets)
.where(and(eq(widgetSecrets.itemId, input.itemId), eq(widgetSecrets.kind, input.kind)));
await ctx.db.insert(widgetSecrets).values({
itemId: input.itemId,
kind: input.kind,
value: encryptSecret(input.value),
updatedAt: new Date(),
});
}),
deleteSecret: protectedProcedure
.input(z.object({ itemId: z.string(), kind: secretKindSchema }))
.mutation(async ({ ctx, input }) => {
await resolveItemWithBoardAccess(ctx, input.itemId);
await ctx.db
.delete(widgetSecrets)
.where(and(eq(widgetSecrets.itemId, input.itemId), eq(widgetSecrets.kind, input.kind)));
}),
getConfiguredKinds: protectedProcedure
.input(z.object({ itemId: z.string() }))
.query(async ({ ctx, input }) => {
await resolveItemWithBoardAccess(ctx, input.itemId);
const secrets = await ctx.db.query.widgetSecrets.findMany({
where: eq(widgetSecrets.itemId, input.itemId),
columns: { kind: true },
});
return secrets.map((s: { kind: string }) => s.kind);
}),
});