diff --git a/.vscode/settings.json b/.vscode/settings.json index aa5d827ff..844d89828 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "typescript.tsdk": "node_modules\\typescript\\lib", + "js/ts.tsdk.path": "node_modules\\typescript\\lib", "js/ts.implicitProjectConfig.experimentalDecorators": true, "cSpell.words": [ "ajnart", diff --git a/apps/docs/docs/advanced/single-sign-on/index.mdx b/apps/docs/docs/advanced/single-sign-on/index.mdx index b81772480..7dab32729 100644 --- a/apps/docs/docs/advanced/single-sign-on/index.mdx +++ b/apps/docs/docs/advanced/single-sign-on/index.mdx @@ -124,6 +124,8 @@ Homarr supports multiple authentication options, from internal userbase (credent Roles are fetched from group claims. This can also be changed to roles for example added to a azure app registration by using the `AUTH_OIDC_GROUPS_ATTRIBUTE`. + If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled. +
Example Setup @@ -197,6 +199,7 @@ Homarr supports multiple authentication options, from internal userbase (credent | ``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_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_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_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 | diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx index 2b304a1f9..578a6402f 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/_add-group-member.tsx @@ -4,6 +4,7 @@ import { useCallback } from "react"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; +import type { SupportedAuthProvider } from "@homarr/definitions"; import { useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; @@ -13,9 +14,10 @@ import { MobileAffixButton } from "~/components/manage/mobile-affix-button"; interface AddGroupMemberProps { groupId: string; presentUserIds: string[]; + allowedProviders: SupportedAuthProvider[]; } -export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps) => { +export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => { const tMembersAdd = useScopedI18n("group.action.addMember"); const { mutateAsync } = clientApi.group.addMember.useMutation(); const { openModal } = useModalAction(UserSelectModal); @@ -32,13 +34,13 @@ export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps) await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`); }, presentUserIds, - excludeExternalProviders: true, + allowedProviders, }, { title: tMembersAdd("label"), }, ); - }, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]); + }, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]); return {tMembersAdd("label")}; }; diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx index f0a6d879a..08ef65a49 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/members/page.tsx @@ -1,12 +1,27 @@ import { notFound } from "next/navigation"; -import { Alert, Anchor, Center, Group, Stack, Table, TableTbody, TableTd, TableTr, Text, Title } from "@mantine/core"; +import { + Alert, + Anchor, + Center, + Group, + Stack, + Table, + TableTbody, + TableTd, + TableTr, + Text, + Title, +} from "@mantine/core"; import { IconExclamationCircle } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { api } from "@homarr/api/server"; -import { env } from "@homarr/auth/env"; import { auth } from "@homarr/auth/next"; -import { isProviderEnabled } from "@homarr/auth/server"; +import { + getGroupMemberManagementType, + getLocallyManageableProviders, + isGroupMembershipManagedLocally, +} from "@homarr/auth/server"; import { everyoneGroup } from "@homarr/definitions"; import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { Link, SearchInput, UserAvatar } from "@homarr/ui"; @@ -40,14 +55,17 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) { const filteredMembers = searchParams.search ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase())) + group.members.filter((member) => + member.name + ?.toLowerCase() + .includes(searchParams.search!.trim().toLowerCase()), + ) : group.members; - const providerTypes = isProviderEnabled("credentials") - ? env.AUTH_PROVIDERS.length > 1 - ? "mixed" - : "credentials" - : "external"; + // "local" = every enabled provider managed locally, "external" = none, "mixed" = some. + const managementType = getGroupMemberManagementType(); + const canManageMembers = managementType !== "external"; + const allowedProviders = getLocallyManageableProviders(); return ( @@ -56,17 +74,28 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) { {isReserved ? ( ) : ( - providerTypes !== "credentials" && ( - }> - {t(`group.memberNotice.${providerTypes}`)} + managementType !== "local" && ( + } + > + {t(`group.memberNotice.${managementType}`)} ) )} - - {isProviderEnabled("credentials") && !isReserved && ( - member.id)} /> + + {canManageMembers && !isReserved && ( + member.id)} + allowedProviders={allowedProviders} + /> )} {filteredMembers.length === 0 && ( @@ -79,7 +108,12 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) { {filteredMembers.map((member) => ( - + ))}
@@ -94,6 +128,8 @@ interface RowProps { } const Row = ({ member, groupId, disabled }: RowProps) => { + const canBeRemoved = isGroupMembershipManagedLocally(member.provider); + return ( @@ -105,7 +141,9 @@ const Row = ({ member, groupId, disabled }: RowProps) => { - {member.provider === "credentials" && !disabled && } + {canBeRemoved && !disabled && ( + + )} ); diff --git a/apps/nextjs/src/components/access/user-select-modal.tsx b/apps/nextjs/src/components/access/user-select-modal.tsx index 099054747..21f35e4eb 100644 --- a/apps/nextjs/src/components/access/user-select-modal.tsx +++ b/apps/nextjs/src/components/access/user-select-modal.tsx @@ -5,6 +5,7 @@ import { IconCheck } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import type { SupportedAuthProvider } from "@homarr/definitions"; import { useForm } from "@homarr/form"; import { createModal } from "@homarr/modals"; import { useI18n } from "@homarr/translation/client"; @@ -12,7 +13,7 @@ import { UserAvatar } from "@homarr/ui"; interface InnerProps { presentUserIds: string[]; - excludeExternalProviders?: boolean; + allowedProviders?: SupportedAuthProvider[]; onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise; confirmLabel?: string; } @@ -24,7 +25,7 @@ interface UserSelectFormType { export const UserSelectModal = createModal(({ actions, innerProps }) => { const t = useI18n(); const { data: users, isPending } = clientApi.user.selectable.useQuery({ - excludeExternalProviders: innerProps.excludeExternalProviders, + providers: innerProps.allowedProviders, }); const [loading, setLoading] = useState(false); const form = useForm(); diff --git a/packages/api/src/router/group.ts b/packages/api/src/router/group.ts index 13fb4fdd3..7c00ff125 100644 --- a/packages/api/src/router/group.ts +++ b/packages/api/src/router/group.ts @@ -1,11 +1,12 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; +import { canManageGroupMembersLocally, isGroupMembershipManagedLocally } from "@homarr/auth/server"; import { createId } from "@homarr/common"; import type { Database } from "@homarr/db"; import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db"; import { getMaxGroupPositionAsync } from "@homarr/db/queries"; -import { groupMembers, groupPermissions, groups } from "@homarr/db/schema"; +import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema"; import { everyoneGroup } from "@homarr/definitions"; import { byIdSchema, paginatedSchema } from "@homarr/validation/common"; import { @@ -18,7 +19,6 @@ import { } from "@homarr/validation/group"; import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc"; -import { throwIfCredentialsDisabled } from "./invite/checks"; import { nextOnboardingStepAsync } from "./onboard/onboard-queries"; export const groupRouter = createTRPCRouter({ @@ -315,10 +315,10 @@ export const groupRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { await throwIfGroupNotFoundAsync(ctx.db, input.groupId); await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId); - throwIfCredentialsDisabled(); + throwIfGroupMembersCannotBeManagedLocally(); const user = await ctx.db.query.users.findFirst({ - where: eq(groups.id, input.userId), + where: eq(users.id, input.userId), }); if (!user) { @@ -328,6 +328,13 @@ export const groupRouter = createTRPCRouter({ }); } + if (!isGroupMembershipManagedLocally(user.provider)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User's provider is not managed locally", + }); + } + await ctx.db.insert(groupMembers).values({ groupId: input.groupId, userId: input.userId, @@ -339,7 +346,7 @@ export const groupRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { await throwIfGroupNotFoundAsync(ctx.db, input.groupId); await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId); - throwIfCredentialsDisabled(); + throwIfGroupMembersCannotBeManagedLocally(); await ctx.db .delete(groupMembers) @@ -371,6 +378,15 @@ const throwIfGroupNameIsReservedAsync = async (db: Database, id: string) => { } }; +const throwIfGroupMembersCannotBeManagedLocally = () => { + if (!canManageGroupMembersLocally()) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Group members cannot be managed locally", + }); + } +}; + const throwIfGroupNotFoundAsync = async (db: Database, id: string) => { const group = await db.query.groups.findFirst({ where: eq(groups.id, id), diff --git a/packages/api/src/router/test/group.spec.ts b/packages/api/src/router/test/group.spec.ts index 1f5b461cc..5deeb3e2f 100644 --- a/packages/api/src/router/test/group.spec.ts +++ b/packages/api/src/router/test/group.spec.ts @@ -748,7 +748,45 @@ describe("addMember should add member to group", () => { }); // Assert - await expect(actAsync()).rejects.toThrow("Credentials provider is disabled"); + await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally"); + }); + + test("with a user from an externally managed provider it should throw FORBIDDEN error", async () => { + // Arrange + const db = createDb(); + const spy = vi.spyOn(env, "env", "get"); + spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials", "ldap"] } as never); + const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession }); + + const groupId = createId(); + const userId = createId(); + await db.insert(users).values([ + { + id: userId, + name: "User", + provider: "ldap", + }, + { + id: defaultOwnerId, + name: "Creator", + }, + ]); + await db.insert(groups).values({ + id: groupId, + name: "Group", + ownerId: defaultOwnerId, + position: 1, + }); + + // Act + const actAsync = async () => + await caller.addMember({ + groupId, + userId, + }); + + // Assert + await expect(actAsync()).rejects.toThrow("User's provider is not managed locally"); }); }); @@ -872,7 +910,7 @@ describe("removeMember should remove member from group", () => { }); // Assert - await expect(actAsync()).rejects.toThrow("Credentials provider is disabled"); + await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally"); }); }); diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index eee626026..c349f6cec 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -7,12 +7,12 @@ import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth"; import { createId } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; import type { Database } from "@homarr/db"; -import { and, eq, handleTransactionsAsync, like } from "@homarr/db"; +import { and, eq, handleTransactionsAsync, inArray, like } from "@homarr/db"; import { getMaxGroupPositionAsync } from "@homarr/db/queries"; import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema"; import { selectUserSchema } from "@homarr/db/validationSchemas"; import type { SupportedAuthProvider } from "@homarr/definitions"; -import { credentialsAdminGroup } from "@homarr/definitions"; +import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions"; import { byIdSchema } from "@homarr/validation/common"; import type { userBaseCreateSchema } from "@homarr/validation/user"; import { @@ -257,7 +257,7 @@ export const userRouter = createTRPCRouter({ }), // Is protected because also used in board access / integration access forms selectable: protectedProcedure - .input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional()) + .input(z.object({ providers: z.array(z.enum(supportedAuthProviders)).optional() }).optional()) .output( z.array( selectUserSchema.pick({ @@ -284,7 +284,7 @@ export const userRouter = createTRPCRouter({ image: true, email: true, }, - where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined, + where: input?.providers ? inArray(users.provider, input.providers) : undefined, }); }), search: permissionRequiredProcedure diff --git a/packages/auth/env.ts b/packages/auth/env.ts index 6fb7048dd..62a018d46 100644 --- a/packages/auth/env.ts +++ b/packages/auth/env.ts @@ -37,6 +37,7 @@ export const env = createEnv({ AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false), AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"), AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token + AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: createBooleanSchema(false), // When enabled, group memberships of oidc users are managed locally instead of synced from the groups claim AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(), AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false), AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false), diff --git a/packages/auth/events.ts b/packages/auth/events.ts index 2593c418b..7f8ecdc33 100644 --- a/packages/auth/events.ts +++ b/packages/auth/events.ts @@ -31,7 +31,12 @@ export const createSignInEventHandler = (db: Database): Exclude { // The question mark is placed there because isProviderEnabled is called during static build of about page // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return env.AUTH_PROVIDERS?.includes(provider); }; + +/** + * Whether a single provider's group memberships are managed locally (manually via UI/API). + * True for credentials users, and for oidc users when AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT is enabled. + */ +export const isGroupMembershipManagedLocally = (provider: SupportedAuthProvider): boolean => { + switch (provider) { + case "credentials": + return true; + case "oidc": + return env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT; + default: + // ldap + any provider added later: externally managed (synced, not editable + // here) until explicitly given a case above. New providers safe-by-default. + return false; + } +}; + +export const getEnabledProviders = (): SupportedAuthProvider[] => supportedAuthProviders.filter(isProviderEnabled); + +/** + * The enabled providers whose group memberships can be managed locally. + */ +export const getLocallyManageableProviders = (): SupportedAuthProvider[] => + getEnabledProviders().filter(isGroupMembershipManagedLocally); + +/** + * Classifies group-member management across all enabled providers: + * - "local": every enabled provider is managed locally (no member is synced externally) + * - "external": no enabled provider is managed locally (every member is synced externally) + * - "mixed": some are managed locally, some externally (only a subset of members is editable here) + */ +export const getGroupMemberManagementType = (): GroupMemberManagementType => { + const enabled = getEnabledProviders(); + const manageable = enabled.filter(isGroupMembershipManagedLocally); + + if (manageable.length === enabled.length) return "local"; + if (manageable.length === 0) return "external"; + return "mixed"; +}; + +/** + * Whether group memberships can be managed locally for at least one enabled provider. + */ +export const canManageGroupMembersLocally = (): boolean => getLocallyManageableProviders().length > 0; diff --git a/packages/auth/providers/test/check-provider.spec.ts b/packages/auth/providers/test/check-provider.spec.ts new file mode 100644 index 000000000..fa7f2ffb2 --- /dev/null +++ b/packages/auth/providers/test/check-provider.spec.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import type { SupportedAuthProvider } from "@homarr/definitions"; + +import * as env from "../../env"; +import { + canManageGroupMembersLocally, + getGroupMemberManagementType, + getLocallyManageableProviders, + isGroupMembershipManagedLocally, +} from "../check-provider"; + +const mockEnv = (providers: SupportedAuthProvider[], oidcLocalManagement = false) => { + vi.spyOn(env, "env", "get").mockReturnValue({ + AUTH_PROVIDERS: providers, + AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: oidcLocalManagement, + } as never); +}; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("isGroupMembershipManagedLocally", () => { + test("credentials is always managed locally", () => { + mockEnv(["credentials"]); + expect(isGroupMembershipManagedLocally("credentials")).toBe(true); + }); + + test("oidc follows AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT", () => { + mockEnv(["oidc"], true); + expect(isGroupMembershipManagedLocally("oidc")).toBe(true); + + mockEnv(["oidc"], false); + expect(isGroupMembershipManagedLocally("oidc")).toBe(false); + }); + + test("ldap is never managed locally", () => { + mockEnv(["ldap"]); + expect(isGroupMembershipManagedLocally("ldap")).toBe(false); + }); +}); + +describe("getGroupMemberManagementType", () => { + test("credentials only is local", () => { + mockEnv(["credentials"]); + expect(getGroupMemberManagementType()).toBe("local"); + }); + + test("credentials + oidc with local management is local", () => { + mockEnv(["credentials", "oidc"], true); + expect(getGroupMemberManagementType()).toBe("local"); + }); + + test("credentials + oidc without local management is mixed", () => { + mockEnv(["credentials", "oidc"], false); + expect(getGroupMemberManagementType()).toBe("mixed"); + }); + + test("credentials + ldap is mixed", () => { + mockEnv(["credentials", "ldap"]); + expect(getGroupMemberManagementType()).toBe("mixed"); + }); + + test("ldap only is external", () => { + mockEnv(["ldap"]); + expect(getGroupMemberManagementType()).toBe("external"); + }); + + test("oidc only without local management is external", () => { + mockEnv(["oidc"], false); + expect(getGroupMemberManagementType()).toBe("external"); + }); +}); + +describe("getLocallyManageableProviders", () => { + test("returns only the enabled, locally managed providers", () => { + mockEnv(["credentials", "oidc", "ldap"], true); + expect(getLocallyManageableProviders()).toEqual(["credentials", "oidc"]); + + mockEnv(["credentials", "oidc", "ldap"], false); + expect(getLocallyManageableProviders()).toEqual(["credentials"]); + + mockEnv(["ldap"]); + expect(getLocallyManageableProviders()).toEqual([]); + }); +}); + +describe("canManageGroupMembersLocally", () => { + test("is true when at least one enabled provider is managed locally", () => { + mockEnv(["credentials", "ldap"]); + expect(canManageGroupMembersLocally()).toBe(true); + + mockEnv(["oidc"], true); + expect(canManageGroupMembersLocally()).toBe(true); + }); + + test("is false when every enabled provider is external", () => { + mockEnv(["ldap"]); + expect(canManageGroupMembersLocally()).toBe(false); + + mockEnv(["oidc"], false); + expect(canManageGroupMembersLocally()).toBe(false); + }); +}); diff --git a/packages/auth/server.ts b/packages/auth/server.ts index 913086ece..be063a769 100644 --- a/packages/auth/server.ts +++ b/packages/auth/server.ts @@ -1,4 +1,11 @@ export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions"; export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions"; -export { isProviderEnabled } from "./providers/check-provider"; +export { + isProviderEnabled, + isGroupMembershipManagedLocally, + getLocallyManageableProviders, + getGroupMemberManagementType, + canManageGroupMembersLocally, +} from "./providers/check-provider"; +export type { GroupMemberManagementType } from "./providers/check-provider"; export { createSessionCallback, createSessionAsync } from "./callbacks"; diff --git a/packages/auth/test/events.spec.ts b/packages/auth/test/events.spec.ts index ad6178ab2..c11eebdff 100644 --- a/packages/auth/test/events.spec.ts +++ b/packages/auth/test/events.spec.ts @@ -12,11 +12,13 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions"; import { createSignInEventHandler } from "../events"; vi.mock("next-auth", () => ({})); +const mockEnv = vi.hoisted(() => ({ + AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey", + AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: false, +})); vi.mock("../env", () => { return { - env: { - AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey", - }, + env: mockEnv, }; }); // eslint-disable-next-line @typescript-eslint/consistent-type-imports @@ -198,6 +200,30 @@ describe("createSignInEventHandler should create signInEventHandler", () => { }); expect(dbGroupMembers?.groupId).toBe("1"); }); + test("should not synchronize groups when AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT is enabled", async () => { + // Arrange + mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = true; + const db = createDb(); + await createUserAsync(db); + await createGroupAsync(db); + const eventHandler = createSignInEventHandler(db); + + // Act + await eventHandler?.({ + user: { id: "1", name: "test" }, + profile: { preferred_username: "test", someRandomGroupsKey: ["test"] }, + account: null, + }); + + // Assert + const dbGroupMembers = await db.query.groupMembers.findFirst({ + where: eq(groupMembers.userId, "1"), + }); + expect(dbGroupMembers).toBeUndefined(); + + // Cleanup + mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = false; + }); }); test.each([ ["ldap" as const, { name: "test-new" }, undefined], diff --git a/packages/integrations/test/technitium.spec.ts b/packages/integrations/test/technitium.spec.ts index e8750af0b..99c9d2610 100644 --- a/packages/integrations/test/technitium.spec.ts +++ b/packages/integrations/test/technitium.spec.ts @@ -2,7 +2,15 @@ import type { StartedTestContainer } from "testcontainers"; import { GenericContainer, Wait } from "testcontainers"; -import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest"; +import { + beforeAll, + beforeEach, + afterAll, + describe, + expect, + test, + vi, +} from "vitest"; import { createDb } from "@homarr/db/test"; @@ -23,10 +31,15 @@ vi.mock("@homarr/db", async (importActual) => { vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports - const actual = await importActual(); + const actual = + await importActual< + typeof import("@homarr/core/infrastructure/certificates") + >(); return { ...actual, - getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])), + getTrustedCertificateHostnamesAsync: vi + .fn() + .mockImplementation(() => Promise.resolve([])), }; }); @@ -77,7 +90,9 @@ const INTEGRATION_ID = "test-technitium"; const makeInput = ( baseUrl: string, - auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string }, + auth: + | { kind: "credentials"; password?: string } + | { kind: "apiKey"; value: string }, ): IntegrationInput => ({ id: INTEGRATION_ID, name: "Technitium DNS", @@ -94,7 +109,10 @@ const makeInput = ( // Fetch an API token appropriate for the server version. // v15 has a dedicated createToken endpoint; legacy reuses the session token from login. -async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise { +async function fetchApiToken( + baseUrl: string, + apiVersion: TechnitiumVersion, +): Promise { if (apiVersion === "v15") { // v15 exposes a dedicated endpoint for non-expiring API tokens. const res = await fetch( @@ -104,7 +122,9 @@ async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Pr } // Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session // token that can be used as a long-lived API key via the ?token= query parameter. - const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`); + const res = await fetch( + `${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`, + ); return ((await res.json()) as { token: string }).token; } @@ -124,12 +144,16 @@ const pool: Record = {}; beforeAll(async () => { await Promise.all( VERSIONS.map(async ({ tag, apiVersion }) => { - const container = await new GenericContainer(`technitium/dns-server:${tag}`) + const container = await new GenericContainer( + `technitium/dns-server:${tag}`, + ) .withExposedPorts(5380) // Explicitly set the admin password so all versions use the same credentials. // Without this, older versions may generate a random password on first run. .withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD }) - .withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully.")) + .withWaitStrategy( + Wait.forLogMessage("Technitium DNS Server was started successfully."), + ) .start(); const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`; const apiToken = await fetchApiToken(baseUrl, apiVersion); @@ -139,203 +163,238 @@ beforeAll(async () => { }, 120_000); afterAll(async () => { - await Promise.all(Object.values(pool).map(({ container }) => container.stop())); + await Promise.all( + Object.values(pool).map(({ container }) => container.stop()), + ); }); // ─── version-parameterised suites ──────────────────────────────────────────── -describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => { - let baseUrl: string; - let apiToken: string; +describe.each(VERSIONS)( + "Technitium DNS $tag ($apiVersion)", + ({ tag, apiVersion }) => { + let baseUrl: string; + let apiToken: string; - beforeAll(() => { - const entry = pool[tag]; - if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`); - ({ baseUrl, apiToken } = entry); - }); + beforeAll(() => { + const entry = pool[tag]; + if (!entry) + throw new Error(`Container pool entry for ${tag} was not initialized`); + ({ baseUrl, apiToken } = entry); + }); - beforeEach(() => { - sessionState[INTEGRATION_ID] = null; - }); + beforeEach(() => { + sessionState[INTEGRATION_ID] = null; + }); - // ── factory ──────────────────────────────────────────────────────────────── + // ── factory ──────────────────────────────────────────────────────────────── - describe("createTechnitiumDnsIntegrationAsync (factory)", () => { - test("factory creates a working integration (auto-detects version)", async () => { - const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" })); + describe("createTechnitiumDnsIntegrationAsync (factory)", () => { + test("factory creates a working integration (auto-detects version)", async () => { + const integration = await createTechnitiumDnsIntegrationAsync( + makeInput(baseUrl, { kind: "credentials" }), + ); - const result = await integration.getSummaryAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("enabled"); - expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); - }, 20_000); - }); + expect(result.status).toBe("enabled"); + expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); + }, 20_000); + }); - // ── getSummaryAsync ──────────────────────────────────────────────────────── + // ── getSummaryAsync ──────────────────────────────────────────────────────── - describe("getSummaryAsync", () => { - test("returns valid summary with credentials", async () => { - const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + describe("getSummaryAsync", () => { + test("returns valid summary with credentials", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + ); - const result = await integration.getSummaryAsync(); + const result = await integration.getSummaryAsync(); - expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); - expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0); - expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0); - expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0); - expect(result.status).toBe("enabled"); - }, 20_000); + expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); + expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0); + expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0); + expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0); + expect(result.status).toBe("enabled"); + }, 20_000); - test("returns valid summary with API key", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "apiKey", value: apiToken }), - apiVersion, - ); + test("returns valid summary with API key", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "apiKey", value: apiToken }), + apiVersion, + ); - const result = await integration.getSummaryAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("enabled"); - expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); - }, 20_000); + expect(result.status).toBe("enabled"); + expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0); + }, 20_000); - test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => { - const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + ); - const result = await integration.getSummaryAsync(); + const result = await integration.getSummaryAsync(); - expect(typeof result.domainsBeingBlocked).toBe("number"); - expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0); - }, 20_000); - }); + expect(typeof result.domainsBeingBlocked).toBe("number"); + expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0); + }, 20_000); + }); - // ── session caching ──────────────────────────────────────────────────────── + // ── session caching ──────────────────────────────────────────────────────── - describe("session caching", () => { - test("login is performed once and token is cached for subsequent calls", async () => { - const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + describe("session caching", () => { + test("login is performed once and token is cached for subsequent calls", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + ); - await integration.getSummaryAsync(); - const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token; - expect(tokenAfterFirst).toBeDefined(); + await integration.getSummaryAsync(); + const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token; + expect(tokenAfterFirst).toBeDefined(); - await integration.getSummaryAsync(); - const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token; + await integration.getSummaryAsync(); + const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token; - expect(tokenAfterSecond).toBe(tokenAfterFirst); - }, 20_000); + expect(tokenAfterSecond).toBe(tokenAfterFirst); + }, 20_000); - test("recovers from an expired/invalid stored token by re-logging in", async () => { - // Plant a bad token with the correct version so it hits the right API path before failing - sessionState[INTEGRATION_ID] = { token: "deliberately-invalid-token", version: apiVersion }; + test("recovers from an expired/invalid stored token by re-logging in", async () => { + // Plant a bad token with the correct version so it hits the right API path before failing + sessionState[INTEGRATION_ID] = { + token: "deliberately-invalid-token", + version: apiVersion, + }; - const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion); + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + apiVersion, + ); - const result = await integration.getSummaryAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("enabled"); - expect(sessionState[INTEGRATION_ID]).not.toBeNull(); - expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token"); - }, 20_000); - }); + expect(result.status).toBe("enabled"); + expect(sessionState[INTEGRATION_ID]).not.toBeNull(); + expect(sessionState[INTEGRATION_ID]?.token).not.toBe( + "deliberately-invalid-token", + ); + }, 20_000); + }); - // ── blocking state changes ───────────────────────────────────────────────── + // ── blocking state changes ───────────────────────────────────────────────── - describe("blocking state changes", () => { - test("disableAsync permanently disables blocking", async () => { - const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + describe("blocking state changes", () => { + test("disableAsync permanently disables blocking", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + ); - await integration.disableAsync(); - const result = await integration.getSummaryAsync(); + await integration.disableAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("disabled"); + expect(result.status).toBe("disabled"); - await integration.enableAsync(); // restore - }, 20_000); + await integration.enableAsync(); // restore + }, 20_000); - test("enableAsync re-enables blocking after permanent disable", async () => { - const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + test("enableAsync re-enables blocking after permanent disable", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + ); - await integration.disableAsync(); - await integration.enableAsync(); - const result = await integration.getSummaryAsync(); + await integration.disableAsync(); + await integration.enableAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("enabled"); - }, 20_000); + expect(result.status).toBe("enabled"); + }, 20_000); - test("disableAsync with duration temporarily disables blocking", async () => { - const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + test("disableAsync with duration temporarily disables blocking", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + ); - await integration.disableAsync(120); // 120s → 2 min - const result = await integration.getSummaryAsync(); + await integration.disableAsync(120); // 120s → 2 min + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("disabled"); + expect(result.status).toBe("disabled"); - await integration.enableAsync(); // restore - }, 20_000); + await integration.enableAsync(); // restore + }, 20_000); - test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => { - const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + ); - await integration.disableAsync(1); - const result = await integration.getSummaryAsync(); + await integration.disableAsync(1); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("disabled"); + expect(result.status).toBe("disabled"); - await integration.enableAsync(); // restore - }, 20_000); + await integration.enableAsync(); // restore + }, 20_000); - test("enableAsync cancels an active timed disable immediately", async () => { - const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" })); + test("enableAsync cancels an active timed disable immediately", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + ); - await integration.disableAsync(600); // 10 minutes - await integration.enableAsync(); - const result = await integration.getSummaryAsync(); + await integration.disableAsync(600); // 10 minutes + await integration.enableAsync(); + const result = await integration.getSummaryAsync(); - expect(result.status).toBe("enabled"); - }, 20_000); - }); + expect(result.status).toBe("enabled"); + }, 20_000); + }); - // ── testConnectionAsync ──────────────────────────────────────────────────── + // ── testConnectionAsync ──────────────────────────────────────────────────── - describe("testConnectionAsync", () => { - test("succeeds with valid credentials", async () => { - const result = await new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials" }), - ).testConnectionAsync(); - expect(result.success).toBe(true); - }, 20_000); + describe("testConnectionAsync", () => { + test("succeeds with valid credentials", async () => { + const result = await new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "credentials" }), + ).testConnectionAsync(); + expect(result.success).toBe(true); + }, 20_000); - test("succeeds with valid API key", async () => { - const result = await new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "apiKey", value: apiToken }), - apiVersion, - ).testConnectionAsync(); - expect(result.success).toBe(true); - }, 20_000); + test("succeeds with valid API key", async () => { + const result = await new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "apiKey", value: apiToken }), + apiVersion, + ).testConnectionAsync(); + expect(result.success).toBe(true); + }, 20_000); - test("fails with wrong password — returns authorization error", async () => { - const integration = new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "credentials", password: "wrong-password" }), - ); + test("fails with wrong password — returns authorization error", async () => { + const integration = new TechnitiumDnsIntegration( + makeInput(baseUrl, { + kind: "credentials", + password: "wrong-password", + }), + ); - const result = await integration.testConnectionAsync(); + const result = await integration.testConnectionAsync(); - expect(result.success).toBe(false); - if (result.success) return; - expect(result.error).toBeInstanceOf(TestConnectionError); - expect(result.error.type).toBe("authorization"); - }, 20_000); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(TestConnectionError); + expect(result.error.type).toBe("authorization"); + }, 20_000); - test("fails with invalid API key — returns authorization error", async () => { - const result = await new TechnitiumDnsIntegration( - makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }), - apiVersion, - ).testConnectionAsync(); + test("fails with invalid API key — returns authorization error", async () => { + const result = await new TechnitiumDnsIntegration( + makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }), + apiVersion, + ).testConnectionAsync(); - expect(result.success).toBe(false); - if (result.success) return; - expect(result.error).toBeInstanceOf(TestConnectionError); - expect(result.error.type).toBe("authorization"); - }, 20_000); - }); -}); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(TestConnectionError); + expect(result.error.type).toBe("authorization"); + }, 20_000); + }); + }, +);