diff --git a/src/app/(dashboard)/integrations/entra-device-auth/layout.tsx b/src/app/(dashboard)/integrations/entra-device-auth/layout.tsx
new file mode 100644
index 00000000..a839fd8e
--- /dev/null
+++ b/src/app/(dashboard)/integrations/entra-device-auth/layout.tsx
@@ -0,0 +1,9 @@
+import { globalMetaTitle } from "@utils/meta";
+import type { Metadata } from "next";
+import BlankLayout from "@/layouts/BlankLayout";
+
+export const metadata: Metadata = {
+ title: `Entra Device Auth - ${globalMetaTitle}`,
+};
+
+export default BlankLayout;
diff --git a/src/app/(dashboard)/integrations/entra-device-auth/page.tsx b/src/app/(dashboard)/integrations/entra-device-auth/page.tsx
new file mode 100644
index 00000000..fff8bead
--- /dev/null
+++ b/src/app/(dashboard)/integrations/entra-device-auth/page.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import Breadcrumbs from "@components/Breadcrumbs";
+import Paragraph from "@components/Paragraph";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
+import { RestrictedAccess } from "@components/ui/RestrictedAccess";
+import { FingerprintIcon, FolderGit2Icon, SettingsIcon } from "lucide-react";
+import { useSearchParams } from "next/navigation";
+import React, { useMemo } from "react";
+import EntraDeviceIcon from "@/assets/icons/EntraDeviceIcon";
+import { usePermissions } from "@/contexts/PermissionsProvider";
+import PageContainer from "@/layouts/PageContainer";
+import EntraDeviceAuthConfig from "@/modules/integrations/entra-device-auth/EntraDeviceAuthConfig";
+import EntraDeviceMappingsTable from "@/modules/integrations/entra-device-auth/EntraDeviceMappingsTable";
+
+/**
+ * Admin UI for the Entra device authentication integration.
+ *
+ * This page is a thin shell that hosts two horizontally-tabbed panels:
+ * 1. Configuration — singleton integration settings.
+ * 2. Mappings — Entra security group → NetBird auto-groups CRUD.
+ *
+ * Permissions use optional chaining (`permission?.entra_device_auth?.read`)
+ * so that the page stays usable against a management server that doesn't
+ * publish the new permission module yet; admin writes will still be rejected
+ * server-side in that case.
+ */
+export default function EntraDeviceAuthPage() {
+ const { permission } = usePermissions();
+ const queryParams = useSearchParams();
+ const initialTab = useMemo(() => queryParams.get("tab") ?? "config", [
+ queryParams,
+ ]);
+
+ const canRead = permission?.entra_device_auth?.read ?? true;
+
+ return (
+
+
+
+ }
+ />
+ }
+ active
+ />
+
+
Entra Device Auth
+
+ Zero-touch device enrollment for Microsoft Entra-joined machines.
+ Devices hitting /join/entra on the
+ management URL prove their identity with the Entra device
+ certificate and are automatically placed into NetBird groups based
+ on their Entra security-group membership.
+
+
+
+
+
+
+
+
+ Configuration
+
+
+
+ Mappings
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(dashboard)/integrations/layout.tsx b/src/app/(dashboard)/integrations/layout.tsx
new file mode 100644
index 00000000..62e36766
--- /dev/null
+++ b/src/app/(dashboard)/integrations/layout.tsx
@@ -0,0 +1,3 @@
+import BlankLayout from "@/layouts/BlankLayout";
+
+export default BlankLayout;
diff --git a/src/assets/icons/EntraDeviceIcon.tsx b/src/assets/icons/EntraDeviceIcon.tsx
new file mode 100644
index 00000000..a41adc03
--- /dev/null
+++ b/src/assets/icons/EntraDeviceIcon.tsx
@@ -0,0 +1,41 @@
+import * as React from "react";
+
+type Props = {
+ size?: number;
+ className?: string;
+};
+
+/**
+ * A small, neutral icon for the Entra device authentication pages.
+ *
+ * We deliberately avoid Microsoft/Entra trademark-coloured assets: this icon
+ * depicts a device with a fingerprint/shield overlay, matching the rest of
+ * NetBird's internal icon set.
+ */
+export default function EntraDeviceIcon({
+ size = 16,
+ className,
+}: Readonly) {
+ return (
+
+ );
+}
diff --git a/src/interfaces/EntraDeviceAuth.ts b/src/interfaces/EntraDeviceAuth.ts
new file mode 100644
index 00000000..50e27fa6
--- /dev/null
+++ b/src/interfaces/EntraDeviceAuth.ts
@@ -0,0 +1,97 @@
+import { Group } from "@/interfaces/Group";
+
+/**
+ * Resolution strategy for a device that matches multiple mappings.
+ * Mirrors the backend's `types.MappingResolution`.
+ */
+export type EntraDeviceMappingResolution = "strict_priority" | "union";
+
+export const EntraDeviceMappingResolutionOptions: {
+ value: EntraDeviceMappingResolution;
+ label: string;
+ description: string;
+}[] = [
+ {
+ value: "strict_priority",
+ label: "Strict priority",
+ description:
+ "Apply only the single mapping with the lowest priority. Ties are broken deterministically by ID.",
+ },
+ {
+ value: "union",
+ label: "Union",
+ description:
+ "Apply all matched mappings: union of auto-groups, OR on ephemeral, AND on extra DNS labels, earliest expiry wins.",
+ },
+];
+
+/**
+ * Integration-level configuration for Microsoft Entra device authentication.
+ * Mirrors `integrationDTO` in management/server/http/handlers/entra_device_auth/handler.go.
+ */
+export interface EntraDeviceAuth {
+ id?: string;
+ tenant_id: string;
+ client_id: string;
+ /** Write-only; server returns "********" when a secret is already stored. */
+ client_secret?: string;
+ issuer?: string;
+ audience?: string;
+ enabled: boolean;
+ require_intune_compliant: boolean;
+ allow_tenant_only_fallback: boolean;
+ fallback_auto_groups?: string[];
+ mapping_resolution?: EntraDeviceMappingResolution;
+ /** Go-duration string, e.g. "24h". Empty string disables revalidation. */
+ revalidation_interval?: string;
+ created_at?: string;
+ updated_at?: string;
+}
+
+export interface EntraDeviceAuthRequest {
+ tenant_id: string;
+ client_id: string;
+ client_secret?: string;
+ issuer?: string;
+ audience?: string;
+ enabled: boolean;
+ require_intune_compliant: boolean;
+ allow_tenant_only_fallback: boolean;
+ fallback_auto_groups?: string[];
+ mapping_resolution?: EntraDeviceMappingResolution;
+ revalidation_interval?: string;
+}
+
+/**
+ * A single Entra-group → NetBird-auto-groups mapping.
+ * Mirrors `mappingDTO` in management/server/http/handlers/entra_device_auth/handler.go.
+ */
+export interface EntraDeviceMapping {
+ id?: string;
+ name: string;
+ /** Entra Object ID (GUID) of the source security group. "*" for catch-all. */
+ entra_group_id: string;
+ auto_groups: string[];
+ ephemeral: boolean;
+ allow_extra_dns_labels: boolean;
+ /** RFC3339 timestamp or null for "never". */
+ expires_at?: string | null;
+ revoked: boolean;
+ priority: number;
+ created_at?: string;
+ updated_at?: string;
+
+ // Frontend-only decoration (resolved from /groups).
+ groups?: Group[];
+}
+
+export interface EntraDeviceMappingRequest {
+ name: string;
+ entra_group_id: string;
+ auto_groups: string[];
+ ephemeral: boolean;
+ allow_extra_dns_labels: boolean;
+ expires_at?: string | null;
+ revoked: boolean;
+ priority: number;
+}
diff --git a/src/interfaces/Permission.ts b/src/interfaces/Permission.ts
index 4eb77d2d..986ea39e 100644
--- a/src/interfaces/Permission.ts
+++ b/src/interfaces/Permission.ts
@@ -35,6 +35,13 @@ export interface Permissions {
proxy_configuration: Permission;
services: Permission;
+
+ /**
+ * Entra device authentication integration (optional so the UI stays
+ * backwards-compatible with management servers that don't expose this
+ * module yet — all call sites use optional chaining).
+ */
+ entra_device_auth?: Permission;
};
}
diff --git a/src/layouts/Navigation.tsx b/src/layouts/Navigation.tsx
index 4f14c695..a34d8f23 100644
--- a/src/layouts/Navigation.tsx
+++ b/src/layouts/Navigation.tsx
@@ -21,6 +21,7 @@ import { SmallBadge } from "@components/ui/SmallBadge";
import * as React from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
+import EntraDeviceIcon from "@/assets/icons/EntraDeviceIcon";
type Props = {
fullWidth?: boolean;
@@ -213,6 +214,7 @@ export default function Navigation({
/>
+
@@ -283,3 +285,23 @@ const ActivityNavigationItem = () => {
);
};
+
+// Entra device authentication admin UI. The permission module is optional on
+// the backend for backwards compatibility, so we default to showing the link
+// when `entra_device_auth` isn't published — the server-side permission
+// check remains the source of truth, and admins on older builds will simply
+// see a 403 when they hit the endpoints.
+const EntraDeviceAuthNavigationItem = () => {
+ const { permission } = usePermissions();
+ const visible = permission?.entra_device_auth?.read ?? true;
+
+ return (
+ }
+ label="Entra Device Auth"
+ href={"/integrations/entra-device-auth"}
+ exactPathMatch={false}
+ visible={visible}
+ />
+ );
+};
diff --git a/src/modules/integrations/entra-device-auth/EntraDeviceAuthConfig.tsx b/src/modules/integrations/entra-device-auth/EntraDeviceAuthConfig.tsx
new file mode 100644
index 00000000..053571ff
--- /dev/null
+++ b/src/modules/integrations/entra-device-auth/EntraDeviceAuthConfig.tsx
@@ -0,0 +1,415 @@
+import Button from "@components/Button";
+import FancyToggleSwitch from "@components/FancyToggleSwitch";
+import HelpText from "@components/HelpText";
+import { Input } from "@components/Input";
+import { Label } from "@components/Label";
+import { notify } from "@components/Notification";
+import Paragraph from "@components/Paragraph";
+import { PeerGroupSelector } from "@components/PeerGroupSelector";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@components/Select";
+import Separator from "@components/Separator";
+import { useDialog } from "@/contexts/DialogProvider";
+import useFetchApi, { useApiCall } from "@utils/api";
+import { trim } from "lodash";
+import {
+ AlarmClock,
+ GlobeIcon,
+ IdCard,
+ KeyIcon,
+ SaveIcon,
+ ShieldCheckIcon,
+ TagIcon,
+ Trash2,
+} from "lucide-react";
+import React, { useEffect, useMemo, useState } from "react";
+import { useSWRConfig } from "swr";
+import { usePermissions } from "@/contexts/PermissionsProvider";
+import {
+ EntraDeviceAuth,
+ EntraDeviceAuthRequest,
+ EntraDeviceMappingResolution,
+ EntraDeviceMappingResolutionOptions,
+} from "@/interfaces/EntraDeviceAuth";
+import { Group } from "@/interfaces/Group";
+import useGroupHelper from "@/modules/groups/useGroupHelper";
+
+/**
+ * Entra device auth integration configuration (singleton per account).
+ *
+ * Mirrors the backend admin endpoint `/api/integrations/entra-device-auth`.
+ * Allows creating / updating the integration as well as wiping it.
+ */
+export default function EntraDeviceAuthConfig() {
+ const { permission } = usePermissions();
+ const { mutate } = useSWRConfig();
+ const { confirm } = useDialog();
+
+ // Silently tolerate 404 (integration not yet configured) via ignoreError.
+ const { data, isLoading } = useFetchApi(
+ "/integrations/entra-device-auth",
+ true,
+ );
+
+ const saveRequest = useApiCall(
+ "/integrations/entra-device-auth",
+ );
+ const deleteRequest = useApiCall(
+ "/integrations/entra-device-auth",
+ );
+
+ const canRead = permission?.entra_device_auth?.read ?? true;
+ const canCreate = permission?.entra_device_auth?.create ?? true;
+ const canUpdate = permission?.entra_device_auth?.update ?? true;
+ const canDelete = permission?.entra_device_auth?.delete ?? true;
+
+ // Form state
+ const [tenantId, setTenantId] = useState("");
+ const [clientId, setClientId] = useState("");
+ const [clientSecret, setClientSecret] = useState("");
+ const [issuer, setIssuer] = useState("");
+ const [audience, setAudience] = useState("");
+ const [enabled, setEnabled] = useState(true);
+ const [requireIntune, setRequireIntune] = useState(false);
+ const [allowTenantOnlyFallback, setAllowTenantOnlyFallback] = useState(false);
+ const [mappingResolution, setMappingResolution] =
+ useState("strict_priority");
+ const [revalidationInterval, setRevalidationInterval] = useState("");
+
+ const [fallbackGroups, setFallbackGroups, { save: saveFallbackGroups }] =
+ useGroupHelper({
+ initial: [],
+ });
+
+ // Hydrate from server-side config once it arrives.
+ useEffect(() => {
+ if (!data) return;
+ setTenantId(data.tenant_id ?? "");
+ setClientId(data.client_id ?? "");
+ // The server returns "********" when a secret is already stored; keep
+ // the UI empty so we don't accidentally echo it back as plaintext.
+ setClientSecret("");
+ setIssuer(data.issuer ?? "");
+ setAudience(data.audience ?? "");
+ setEnabled(!!data.enabled);
+ setRequireIntune(!!data.require_intune_compliant);
+ setAllowTenantOnlyFallback(!!data.allow_tenant_only_fallback);
+ setMappingResolution(data.mapping_resolution ?? "strict_priority");
+ setRevalidationInterval(data.revalidation_interval ?? "");
+ }, [data]);
+
+ const isEditing = !!data?.id;
+ const hasSecretStored = !!data && !!data.client_secret; // "********" when present
+ const resolvedIssuer = useMemo(
+ () =>
+ issuer ||
+ (tenantId
+ ? `https://login.microsoftonline.com/${tenantId}/v2.0`
+ : "https://login.microsoftonline.com/{tenant}/v2.0"),
+ [issuer, tenantId],
+ );
+
+ const isDisabled = useMemo(() => {
+ if (!trim(tenantId) || !trim(clientId)) return true;
+ // Require a secret only when we don't already have one stored.
+ if (!hasSecretStored && !trim(clientSecret)) return true;
+ return false;
+ }, [tenantId, clientId, clientSecret, hasSecretStored]);
+
+ const buildRequest = async (): Promise => {
+ const groups = await saveFallbackGroups();
+ return {
+ tenant_id: trim(tenantId),
+ client_id: trim(clientId),
+ // Only send the secret when the operator has actually typed a new
+ // one — the backend preserves the existing value otherwise.
+ client_secret: trim(clientSecret) || undefined,
+ issuer: trim(issuer) || undefined,
+ audience: trim(audience) || undefined,
+ enabled,
+ require_intune_compliant: requireIntune,
+ allow_tenant_only_fallback: allowTenantOnlyFallback,
+ fallback_auto_groups: groups.map((g: Group) => g.id!).filter(Boolean),
+ mapping_resolution: mappingResolution,
+ revalidation_interval: trim(revalidationInterval) || undefined,
+ };
+ };
+
+ const submit = async () => {
+ const payload = await buildRequest();
+ notify({
+ title: "Entra Device Auth",
+ description: isEditing
+ ? "Integration updated successfully."
+ : "Integration configured successfully.",
+ promise: (isEditing ? saveRequest.put(payload) : saveRequest.post(payload)).then(
+ () => {
+ mutate("/integrations/entra-device-auth");
+ mutate("/integrations/entra-device-auth/mappings");
+ setClientSecret(""); // prevent the secret from lingering in state
+ },
+ ),
+ loadingMessage: isEditing
+ ? "Updating integration..."
+ : "Configuring integration...",
+ });
+ };
+
+ const handleDelete = async () => {
+ const choice = await confirm({
+ title: "Delete Entra Device Auth integration?",
+ description:
+ "This disables zero-touch Entra enrollment and removes all mappings. Peers already joined via Entra will stay registered but won't re-authenticate.",
+ confirmText: "Delete",
+ cancelText: "Cancel",
+ type: "danger",
+ });
+ if (!choice) return;
+ notify({
+ title: "Entra Device Auth",
+ description: "Integration deleted.",
+ promise: deleteRequest.del().then(() => {
+ mutate("/integrations/entra-device-auth");
+ mutate("/integrations/entra-device-auth/mappings");
+ }),
+ loadingMessage: "Deleting integration...",
+ });
+ };
+
+ if (!canRead) {
+ return (
+
+ You don't have permission to view the Entra device authentication
+ integration.
+
+ );
+ }
+
+ return (
+
+
+
+
+
Configuration
+
+ Connect NetBird to your Microsoft Entra tenant so Entra-joined
+ devices can enroll without user interaction. Dedicated endpoint:{" "}
+ /join/entra
+
+
+ {isEditing && (
+
+ )}
+
+
+
+
+
+
+
+ Microsoft Entra tenant GUID (the directory NetBird will trust).
+
+ setTenantId(e.target.value)}
+ customPrefix={}
+ disabled={isLoading}
+ />
+
+
+
+
+
+ Application registered in Entra used for Microsoft Graph lookups.
+
+ setClientId(e.target.value)}
+ customPrefix={}
+ disabled={isLoading}
+ />
+
+
+
+
+
+ {hasSecretStored
+ ? "A secret is already stored. Leave empty to keep it, or enter a new value to rotate."
+ : "Client secret for the app registration. Required on first setup."}
+
+ setClientSecret(e.target.value)}
+ customPrefix={}
+ disabled={isLoading || !(canCreate || canUpdate)}
+ />
+
+
+
+
+
+ OIDC issuer used when validating device tokens. Defaults to{" "}
+ {resolvedIssuer} when empty.
+
+ setIssuer(e.target.value)}
+ customPrefix={}
+ disabled={isLoading}
+ />
+
+
+
+
+
+ Expected aud claim for Entra
+ device tokens. Leave empty when using the default Entra app URI.
+
+ setAudience(e.target.value)}
+ customPrefix={}
+ disabled={isLoading}
+ />
+
+
+
+
+
+
+ Enabled
+ >
+ }
+ helpText="When disabled, the /join/entra endpoint rejects all requests for this account."
+ disabled={!canUpdate && isEditing}
+ />
+
+
+
+ Require Intune compliance
+ >
+ }
+ helpText="Only allow devices marked as compliant by Microsoft Intune. Requires DeviceManagementManagedDevices.Read.All."
+ disabled={!canUpdate && isEditing}
+ />
+
+
+
+ Allow tenant-only fallback
+ >
+ }
+ helpText="If no group-scoped mapping matches, apply the fallback auto-groups below for any valid device from this tenant."
+ disabled={!canUpdate && isEditing}
+ />
+
+ {allowTenantOnlyFallback && (
+
+
+
+ NetBird groups applied when the tenant-only fallback kicks in.
+
+
+
+ )}
+
+
+
+
+ How to combine results when a device matches multiple mappings.
+
+
+
+
+
+ Friendly name shown in the admin UI and activity logs.
+
+ setName(e.target.value)}
+ customPrefix={}
+ />
+
+
+
+
+
+ Microsoft Entra security group Object ID (GUID). Use{" "}
+ * to match any device from the
+ configured tenant.
+
+ setEntraGroupId(e.target.value)}
+ customPrefix={
+
+ }
+ />
+
+
+
+
+
+ Peers enrolled via this mapping are automatically placed in these
+ groups.
+
+
+
+ }
+ color="gray"
+ size="large"
+ />
+ }
+ title="Configure the integration first"
+ description="Set up the Entra Device Auth integration (Configuration tab) before adding mappings. The backend refuses mappings while the integration is missing."
+ />
+
+ );
+ }
+
+ return (
+ <>
+
+ handleEdit(row.original)}
+ searchPlaceholder="Search by name or Entra group ID..."
+ getStartedCard={
+ }
+ color="gray"
+ size="large"
+ />
+ }
+ title="Add your first mapping"
+ description="Map an Entra security group (or the catch-all '*') to a set of NetBird auto-groups. Peers enrolled via /join/entra will be placed accordingly."
+ button={
+
+ }
+ />
+ }
+ rightSide={() => (
+ <>
+ {mappings && mappings.length > 0 && (
+
+ )}
+ >
+ )}
+ >
+ {(table) => (
+ <>
+
+ {
+ mutate("/integrations/entra-device-auth/mappings");
+ mutate("/groups");
+ }}
+ />
+ >
+ )}
+
+ >
+ );
+}