Compare commits

..
Author SHA1 Message Date
ajnart 4ab9f25274 fix(integrations): gate mock integration server-side 2026-09-06 09:47:35 +02:00
12 changed files with 66 additions and 170 deletions

No files matched your search

+2 -2
View File
@@ -58,8 +58,8 @@ TURBO_TELEMETRY_DISABLED=1
# Enable kubernetes tool # Enable kubernetes tool
# ENABLE_KUBERNETES=true # ENABLE_KUBERNETES=true
# Enable mock integration # Enable the development-only mock integration. Never enable this in production.
UNSAFE_ENABLE_MOCK_INTEGRATION=true # UNSAFE_ENABLE_MOCK_INTEGRATION=true
# Public URL contract. Values must be absolute HTTP(S) URLs without credentials, # Public URL contract. Values must be absolute HTTP(S) URLs without credentials,
# query strings, or fragments. Trailing slashes are normalized at startup. # query strings, or fragments. Trailing slashes are normalized at startup.
@@ -133,9 +133,10 @@ External redis is currently not supported with Proxmox Community Scripts
The advanced deployments environment variables should only be used if you know what you are doing. The advanced deployments environment variables should only be used if you know what you are doing.
::: :::
| Environment Variable | Description | | Environment Variable | Description | Default |
| ------------------------ | --------------------------------------------------- | | -------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------- |
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. | | `DB_MIGRATIONS_DISABLED` | Disable database migrations. For example, when migrations are managed separately in Helm deployments. | `false` |
| `UNSAFE_ENABLE_MOCK_INTEGRATION` | Enable the development-only Mock integration. This exposes fabricated data and must not be used in production. | `false` |
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
trailing slashes when Homarr or Docusaurus starts. trailing slashes when Homarr or Docusaurus starts.
@@ -202,9 +202,9 @@ Homarr supports multiple authentication options, from internal userbase (credent
| ``AUTH_OIDC_CLIENT_NAME`` | Display name of provider (in login screen) | OIDC | | ``AUTH_OIDC_CLIENT_NAME`` | Display name of provider (in login screen) | OIDC |
| ``AUTH_OIDC_AUTO_LOGIN`` | Automatically redirect to OIDC login | false | | ``AUTH_OIDC_AUTO_LOGIN`` | Automatically redirect to OIDC login | false |
| ``AUTH_OIDC_SCOPE_OVERWRITE`` | Overwrite default scopes (openid, profile, email) | openid email profile groups | | ``AUTH_OIDC_SCOPE_OVERWRITE`` | Overwrite default scopes (openid, profile, email) | openid email profile groups |
| ``AUTH_OIDC_GROUPS_ATTRIBUTE`` | Attribute used for groups (roles) claim. Dot-separated paths such as `resource_access.homarr.roles` are supported. | groups | | ``AUTH_OIDC_GROUPS_ATTRIBUTE`` | Attribute used for groups (roles) claim | groups |
| ``AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT`` | Stop syncing group memberships of OIDC users from the groups claim and manage them locally via the group members page instead | false | | ``AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT`` | Stop syncing group memberships of OIDC users from the groups claim and manage them locally via the group members page instead | false |
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. Dot-separated paths are supported. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- | | ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false | | ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false | | ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false | | ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
@@ -47,6 +47,9 @@ export default async function IntegrationsNewPage(props: NewIntegrationPageProps
const tCreate = await getI18n("integration.page.create"); const tCreate = await getI18n("integration.page.create");
const currentKind = result.data; const currentKind = result.data;
if (currentKind === "mock" && !env.UNSAFE_ENABLE_MOCK_INTEGRATION) {
notFound();
}
return ( return (
<> <>
+2
View File
@@ -19,6 +19,7 @@ const publicHttpUrl = (variableName: string) =>
export const env = createEnv({ export const env = createEnv({
server: { server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(), KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
DEMO_MODE: createBooleanSchema(false), DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true), DEMO_READ_ONLY: createBooleanSchema(true),
HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"), HOMARR_WEBSITE_URL: publicHttpUrl("HOMARR_WEBSITE_URL").default("https://homarr.dev"),
@@ -27,6 +28,7 @@ export const env = createEnv({
}, },
runtimeEnv: { runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME, KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
UNSAFE_ENABLE_MOCK_INTEGRATION: process.env.UNSAFE_ENABLE_MOCK_INTEGRATION,
DEMO_MODE: process.env.DEMO_MODE, DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY, DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL, HOMARR_WEBSITE_URL: process.env.HOMARR_WEBSITE_URL,
@@ -18,7 +18,12 @@ import {
integrationSecrets, integrationSecrets,
integrationUserPermissions, integrationUserPermissions,
} from "@homarr/db/schema"; } from "@homarr/db/schema";
import type { GroupPermissionKey, IntegrationPermission, IntegrationSecretKind } from "@homarr/definitions"; import type {
GroupPermissionKey,
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
} from "@homarr/definitions";
import { import {
getIntegrationKindsByCategory, getIntegrationKindsByCategory,
getPermissionsWithParents, getPermissionsWithParents,
@@ -39,6 +44,7 @@ import {
} from "@homarr/validation/integration"; } from "@homarr/validation/integration";
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request"; import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { env } from "../../env";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access"; import { throwIfActionForbiddenAsync } from "./integration-access";
@@ -48,6 +54,15 @@ import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" }); const logger = createLogger({ module: "integrationRouter" });
const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch"); const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
const throwIfMockIntegrationIsDisabled = (kind: IntegrationKind) => {
if (kind !== "mock" || env.UNSAFE_ENABLE_MOCK_INTEGRATION) return;
throw new TRPCError({
code: "FORBIDDEN",
message: "Mock integrations are disabled",
});
};
export const integrationRouter = createTRPCRouter({ export const integrationRouter = createTRPCRouter({
getKinds: publicProcedure getKinds: publicProcedure
.meta({ .meta({
@@ -58,12 +73,14 @@ export const integrationRouter = createTRPCRouter({
}, },
}) })
.query(() => { .query(() => {
return objectEntries(integrationDefs).map(([kind, def]) => ({ return objectEntries(integrationDefs)
kind, .filter(([kind]) => kind !== "mock" || env.UNSAFE_ENABLE_MOCK_INTEGRATION)
name: def.name, .map(([kind, def]) => ({
category: def.category, kind,
requiredSecrets: def.secretKinds, name: def.name,
})); category: def.category,
requiredSecrets: def.secretKinds,
}));
}), }),
all: protectedProcedure all: protectedProcedure
.meta({ .meta({
@@ -229,6 +246,8 @@ export const integrationRouter = createTRPCRouter({
}) })
.input(integrationCreateSchema) .input(integrationCreateSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
throwIfMockIntegrationIsDisabled(input.kind);
logger.info("Creating integration", { logger.info("Creating integration", {
name: input.name, name: input.name,
kind: input.kind, kind: input.kind,
@@ -321,6 +321,13 @@ export const onboardRouter = createTRPCRouter({
.requiresStep("setup") .requiresStep("setup")
.input(onboardingCompleteSetupSchema) .input(onboardingCompleteSetupSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
if (
!env.UNSAFE_ENABLE_MOCK_INTEGRATION &&
input.integrations.some((integration) => integration.kind === "mock")
) {
throw new TRPCError({ code: "FORBIDDEN", message: "Mock integrations are disabled" });
}
const boardList = await ctx.db.query.boards.findMany({ const boardList = await ctx.db.query.boards.findMany({
with: { with: {
sections: { with: { layouts: true } }, sections: { with: { layouts: true } },
+10 -5
View File
@@ -9,7 +9,7 @@ import { groupMembers, groups, users } from "@homarr/db/schema";
import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions"; import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
import { env } from "./env"; import { env } from "./env";
import { extractProfileName, getProfileValueByPath } from "./providers/oidc/profile"; import { extractProfileName } from "./providers/oidc/oidc-provider";
const logger = createLogger({ module: "authEvents" }); const logger = createLogger({ module: "authEvents" });
@@ -31,11 +31,16 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
if (!dbUser) throw new Error("User not found"); if (!dbUser) throw new Error("User not found");
const groupsKey = env.AUTH_OIDC_GROUPS_ATTRIBUTE; const groupsKey = env.AUTH_OIDC_GROUPS_ATTRIBUTE;
const profileGroups = profile ? getProfileValueByPath(profile, groupsKey) : undefined;
// Groups from oidc provider are provided from the profile, it's not typed. // Groups from oidc provider are provided from the profile, it's not typed.
if (!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT && dbUser.provider === "oidc" && Array.isArray(profileGroups)) { if (
logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profileGroups)}`); !env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profileGroups as string[]); dbUser.provider === "oidc" &&
profile &&
groupsKey in profile &&
Array.isArray(profile[groupsKey])
) {
logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profile[groupsKey])}`);
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]);
} }
// In ldap-authroization we return the groups from ldap, it's not typed. // In ldap-authroization we return the groups from ldap, it's not typed.
@@ -7,7 +7,6 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import { env } from "../../env"; import { env } from "../../env";
import { createRedirectUri } from "../../redirect"; import { createRedirectUri } from "../../redirect";
import { extractProfileName } from "./profile";
import { assertVerifiedEmailForCredentialsLinking } from "./verified-email"; import { assertVerifiedEmailForCredentialsLinking } from "./verified-email";
export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profile> => ({
@@ -77,3 +76,12 @@ export const OidcProvider = (headers: ReadonlyHeaders | null): OIDCConfig<Profil
// @ts-expect-error `undici` has a `duplex` option // @ts-expect-error `undici` has a `duplex` option
[customFetch]: fetchWithTrustedCertificatesAsync, [customFetch]: fetchWithTrustedCertificatesAsync,
}); });
export const extractProfileName = (profile: Profile) => {
if (!env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE) {
// Use the name as the username if the preferred_username is an email address
return profile.preferred_username?.includes("@") ? profile.name : profile.preferred_username;
}
return profile[env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE as keyof typeof profile] as string;
};
-23
View File
@@ -1,23 +0,0 @@
import type { Profile } from "@auth/core/types";
import { env } from "../../env";
export const getProfileValueByPath = (profile: Profile, path: string): unknown => {
return path.split(".").reduce<unknown>((value, key) => {
if (typeof value !== "object" || value === null || !Object.hasOwn(value, key)) {
return undefined;
}
return (value as Record<string, unknown>)[key];
}, profile);
};
export const extractProfileName = (profile: Profile): string | undefined => {
if (!env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE) {
// Use the name as the username if the preferred_username is an email address
return (profile.preferred_username?.includes("@") ? profile.name : profile.preferred_username) ?? undefined;
}
const profileName = getProfileValueByPath(profile, env.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE);
return typeof profileName === "string" ? profileName : undefined;
};
@@ -1,96 +0,0 @@
import type { Profile } from "@auth/core/types";
import { afterEach, describe, expect, test, vi } from "vitest";
const mockEnv = vi.hoisted(() => ({
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: undefined as string | undefined,
}));
vi.mock("../../../env", () => ({ env: mockEnv }));
import { extractProfileName, getProfileValueByPath } from "../profile";
afterEach(() => {
mockEnv.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE = undefined;
});
describe("getProfileValueByPath", () => {
const profile = {
sub: "user-id",
resource_access: {
homarr: {
roles: ["admins"],
},
},
} satisfies Profile;
test("returns a top-level claim", () => {
expect(getProfileValueByPath(profile, "sub")).toBe("user-id");
});
test("returns a nested claim", () => {
expect(getProfileValueByPath(profile, "resource_access.homarr.roles")).toEqual(["admins"]);
});
test("returns undefined when the path does not exist", () => {
expect(getProfileValueByPath(profile, "resource_access.missing.roles")).toBeUndefined();
});
});
describe("extractProfileName", () => {
test("uses preferred_username when overwrite is not set", () => {
const profile = {
sub: "user-id",
preferred_username: "johndoe",
} satisfies Profile;
expect(extractProfileName(profile)).toBe("johndoe");
});
test("uses name when preferred_username is an email", () => {
const profile = {
sub: "user-id",
preferred_username: "john@example.com",
name: "John Doe",
} satisfies Profile;
expect(extractProfileName(profile)).toBe("John Doe");
});
test("returns undefined when the fallback name is null", () => {
const profile = {
sub: "user-id",
preferred_username: "john@example.com",
name: null,
} satisfies Profile;
expect(extractProfileName(profile)).toBeUndefined();
});
test("returns undefined when preferred_username is missing", () => {
const profile = {
sub: "user-id",
} satisfies Profile;
expect(extractProfileName(profile)).toBeUndefined();
});
test("uses a nested name claim when configured", () => {
mockEnv.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE = "user.profile.display_name";
const profile = {
sub: "user-id",
user: { profile: { display_name: "Nested User" } },
} satisfies Profile;
expect(extractProfileName(profile)).toBe("Nested User");
});
test("returns undefined when the configured claim is not a string", () => {
mockEnv.AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE = "user.profile";
const profile = {
sub: "user-id",
user: { profile: { display_name: "Nested User" } },
} satisfies Profile;
expect(extractProfileName(profile)).toBeUndefined();
});
});
+1 -31
View File
@@ -1,7 +1,7 @@
import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies"; import type { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies";
import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; import type { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { afterEach, describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { eq } from "@homarr/db"; import { eq } from "@homarr/db";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
@@ -21,12 +21,6 @@ vi.mock("../env", () => {
env: mockEnv, env: mockEnv,
}; };
}); });
afterEach(() => {
mockEnv.AUTH_OIDC_GROUPS_ATTRIBUTE = "someRandomGroupsKey";
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = false;
});
// eslint-disable-next-line @typescript-eslint/consistent-type-imports // eslint-disable-next-line @typescript-eslint/consistent-type-imports
type HeadersExport = typeof import("next/headers"); type HeadersExport = typeof import("next/headers");
vi.mock("next/headers", async (importOriginal) => { vi.mock("next/headers", async (importOriginal) => {
@@ -158,30 +152,6 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
}); });
expect(dbGroupMembers?.groupId).toBe("1"); expect(dbGroupMembers?.groupId).toBe("1");
}); });
test("should add missing group membership from a nested claim", async () => {
// Arrange
mockEnv.AUTH_OIDC_GROUPS_ATTRIBUTE = "resource_access.homarr.roles";
const db = createDb();
await createUserAsync(db, "oidc");
await createGroupAsync(db);
const eventHandler = createSignInEventHandler(db);
// Act
await eventHandler?.({
user: { id: "1", name: "test" },
profile: {
preferred_username: "test",
resource_access: { homarr: { roles: ["test"] } },
},
account: null,
});
// Assert
const dbGroupMembers = await db.query.groupMembers.findFirst({
where: eq(groupMembers.userId, "1"),
});
expect(dbGroupMembers?.groupId).toBe("1");
});
test("should remove group membership", async () => { test("should remove group membership", async () => {
// Arrange // Arrange
const db = createDb(); const db = createDb();