Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bd3a7e4
entra_device: Phase 1 server skeleton
thvevirtue Apr 22, 2026
09c4bc2
entra_device: wire into AccountManager + HTTP router
thvevirtue Apr 23, 2026
8c921a7
entra_device: unit tests for enrolment flow + peer-registration integ…
thvevirtue Apr 23, 2026
e7667d5
entra_device: test harness (Dockerfile + compose + synthetic client) …
thvevirtue Apr 23, 2026
783117e
enroll-tester: add --demo mode for in-process end-to-end verification
thvevirtue Apr 24, 2026
b47a05a
docs: user-facing documentation for Entra device auth
thvevirtue Apr 24, 2026
17e718d
entra-test: fix docker-compose flags + config for real bring-up
thvevirtue Apr 24, 2026
9b27c0f
entra-test: seed-account helper + expose Postgres port
thvevirtue Apr 24, 2026
50c249d
docs: record live-tenant verification matrix
thvevirtue Apr 24, 2026
d38984e
client/entradevice: PFX CertProvider + Enroller + tests (Phase 2 core)
thvevirtue Apr 24, 2026
d4228d8
client: netbird entra-enroll subcommand (Phase 2 complete)
thvevirtue Apr 24, 2026
cb3c676
entra_device: defer Windows cert-store provider to a follow-up
thvevirtue Apr 24, 2026
688239f
docs: bump Entra device auth status header (Phase 2 shipped)
thvevirtue Apr 24, 2026
9f04a0c
entra_device: address SonarCloud feedback (complexity + creds)
thvevirtue Apr 24, 2026
07bf8cf
entra_device: address CodeRabbit review round 1
thvevirtue Apr 24, 2026
d8c14e8
entra_device: address SonarCloud round 2 (complexity + Dockerfile hot…
thvevirtue Apr 24, 2026
01b60fc
docs: final documentation polish for Entra device auth
thvevirtue Apr 24, 2026
e22f666
Add end-to-end test for Entra device auth admin + enrolment flow
thvevirtue Apr 25, 2026
429aa96
Address SonarCloud quality gate findings
thvevirtue Apr 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
330 changes: 330 additions & 0 deletions client/cmd/entra_enroll.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,330 @@
package cmd

import (
"context"
"fmt"
"net/url"
"os"
"strings"
"time"

log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"

"github.com/netbirdio/netbird/client/internal/enroll/entradevice"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/util"
)

// Local flags for the subcommand (kept here rather than on the root so they
// don't clutter every other netbird subcommand).
// NOTE: Windows cert-store + TPM-backed CNG signing is the intended
// production path (see docs/ENTRA_DEVICE_AUTH.md "Future work" section).
// It needs either CGO + mingw-w64 in the build chain (smimesign/certstore)
// or a hand-rolled pure-Go wrapper over ncrypt.dll. Neither is in this
// commit; PFX is the currently-supported cert source.
var (
entraPFXPath string
entraPFXPassword string
entraPFXPassEnv string
entraTenantID string
entraHostname string
)

// entraEnrollCmd drives a one-shot Entra device enrolment against the
// management server's /join/entra endpoints and persists the resulting state
// into the active profile's config file.
var entraEnrollCmd = &cobra.Command{
Use: "entra-enroll",
Short: "Enrol this device via the Entra/Intune device-auth endpoint",
Long: `Run the Entra device authentication enrolment flow against a NetBird
management server.

This fetches a challenge nonce from /join/entra/challenge, signs it with the
private key in the supplied PFX certificate, POSTs /join/entra/enroll, and
saves the resulting state (peer id, tenant, auto-groups) into the active
profile's config file.

After successful enrolment the peer is already registered on the server by
its WireGuard public key, so subsequent 'netbird up' calls on the same
profile proceed with the normal gRPC Login without any further user
interaction.

Example:

netbird entra-enroll \
--management-url https://mgmt.example.dk/join/entra \
--entra-tenant 5a7a81b2-99cc-45fc-b6d1-cd01ba176c26 \
--entra-pfx C:\ProgramData\NetBird\device.pfx \
--entra-pfx-password-env NB_ENTRA_PFX_PASSWORD`,
RunE: runEntraEnroll,
}

// runEntraEnroll is the entry point invoked by cobra. Kept as a thin
// orchestrator that delegates to phase-specific helpers so each piece is
// reviewable in isolation and SonarCloud's complexity / length thresholds
// are respected.
func runEntraEnroll(cmd *cobra.Command, _ []string) error {
SetFlagsFromEnvVars(rootCmd)
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
return fmt.Errorf("init log: %w", err)
}
pfxPassword, err := preflightEntraEnroll()
if err != nil {
return err
}

active, configPath, cfg, err := loadOrCreateProfileConfig()
if err != nil {
return err
}
if ok, err := maybeSkipAlreadyEnrolled(cmd, active.Name, cfg); ok {
return err
}

wgPub, err := derivedWGPubKey(cfg)
if err != nil {
return err
}

ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()

state, err := performEntraEnrolment(ctx, cmd, pfxPassword, wgPub)
if err != nil {
return err
}

cleanMgmt, err := persistEnrolmentState(ctx, cfg, configPath, state)
if err != nil {
return err
}
printEnrolmentSuccess(cmd, active.Name, state, cleanMgmt)
log.Infof("entra-enroll succeeded for peer %s", state.PeerID)
return nil
}

// preflightEntraEnroll validates flags + resolves the PFX password + checks
// that --management-url was supplied.
func preflightEntraEnroll() (string, error) {
if err := validateEntraFlags(); err != nil {
return "", err
}
if managementURL == "" {
return "", fmt.Errorf("--management-url is required (and must end with /join/entra)")
}
Comment on lines +114 to +116
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Message claims a suffix check that isn't performed.

The error says --management-url ... must end with /join/entra, but preflightEntraEnroll never validates the suffix. A user passing --management-url https://mgmt.example.com will bypass this check entirely and only hit a later opaque HTTP error. Either actually enforce the suffix or soften the message.

🛠️ Suggested fix
 	if managementURL == "" {
 		return "", fmt.Errorf("--management-url is required (and must end with /join/entra)")
 	}
+	if !strings.HasSuffix(strings.TrimRight(managementURL, "/"), entradevice.EnrolmentPathSuffix) {
+		return "", fmt.Errorf("--management-url %q must end with %s", managementURL, entradevice.EnrolmentPathSuffix)
+	}
 	return resolvePFXPassword()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if managementURL == "" {
return "", fmt.Errorf("--management-url is required (and must end with /join/entra)")
}
if managementURL == "" {
return "", fmt.Errorf("--management-url is required (and must end with /join/entra)")
}
if !strings.HasSuffix(strings.TrimRight(managementURL, "/"), entradevice.EnrolmentPathSuffix) {
return "", fmt.Errorf("--management-url %q must end with %s", managementURL, entradevice.EnrolmentPathSuffix)
}
return resolvePFXPassword()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/cmd/entra_enroll.go` around lines 114 - 116, The error message in
preflightEntraEnroll incorrectly claims managementURL must end with
"/join/entra" but no suffix check exists; update preflightEntraEnroll to
validate that managementURL is non-empty and strings.HasSuffix(managementURL,
"/join/entra") (using the managementURL variable and importing strings if
needed) and return fmt.Errorf("--management-url is required and must end with
/join/entra") when the suffix check fails; alternatively, if you prefer not to
enforce the suffix, remove the suffix text from the error message so it only
requires non-empty managementURL.

return resolvePFXPassword()
}

// loadOrCreateProfileConfig returns the active profile, its config path, and
// a loaded Config. It first tries the ACL-enforcing UpdateOrCreateConfig and
// falls back to a plain WriteJson path for dev boxes where the config dir is
// under a writable but non-system location.
func loadOrCreateProfileConfig() (*profilemanager.Profile, string, *profilemanager.Config, error) {
pm := profilemanager.NewProfileManager()
active, err := pm.GetActiveProfile()
if err != nil {
return nil, "", nil, fmt.Errorf("get active profile: %w", err)
}
configPath, err := active.FilePath()
if err != nil {
return nil, "", nil, fmt.Errorf("get active profile config path: %w", err)
}
cfg, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ManagementURL: managementURL,
ConfigPath: configPath,
})
if err != nil {
log.Warnf("UpdateOrCreateConfig failed (%v) — falling back to direct create (dev/no-ACL path)", err)
cfg, err = directLoadOrCreateProfileConfig(configPath, managementURL)
if err != nil {
return nil, "", nil, fmt.Errorf("load/create profile config (fallback): %w", err)
}
}
return active, configPath, cfg, nil
}

// maybeSkipAlreadyEnrolled reports whether the active profile already carries
// a persisted EntraEnrollState. Returns (true, nil) when the caller should
// exit cleanly, (false, nil) when enrolment should proceed (either no prior
// state, or --force was supplied).
func maybeSkipAlreadyEnrolled(cmd *cobra.Command, profileName string, cfg *profilemanager.Config) (bool, error) {
if cfg.EntraEnroll == nil || cfg.EntraEnroll.PeerID == "" {
return false, nil
}
cmd.Printf("Profile %q is already Entra-enrolled (peer %s, enrolled %s).\n",
profileName, cfg.EntraEnroll.PeerID,
cfg.EntraEnroll.EnrolledAt.Format(time.RFC3339))
cmd.Println("Pass --force to re-enrol.")
if !entraForce {
return true, nil
}
return false, nil
Comment on lines +156 to +163
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

"Pass --force to re-enrol." is printed even when --force is supplied.

When the user already passed --force, the hint is misleading — we tell them to pass a flag they just passed, then proceed anyway. Gate the hint behind the non-force branch.

🛠️ Suggested fix
 	cmd.Printf("Profile %q is already Entra-enrolled (peer %s, enrolled %s).\n",
 		profileName, cfg.EntraEnroll.PeerID,
 		cfg.EntraEnroll.EnrolledAt.Format(time.RFC3339))
-	cmd.Println("Pass --force to re-enrol.")
 	if !entraForce {
+		cmd.Println("Pass --force to re-enrol.")
 		return true, nil
 	}
+	cmd.Println("--force supplied; re-enrolling.")
 	return false, nil
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cmd.Printf("Profile %q is already Entra-enrolled (peer %s, enrolled %s).\n",
profileName, cfg.EntraEnroll.PeerID,
cfg.EntraEnroll.EnrolledAt.Format(time.RFC3339))
cmd.Println("Pass --force to re-enrol.")
if !entraForce {
return true, nil
}
return false, nil
cmd.Printf("Profile %q is already Entra-enrolled (peer %s, enrolled %s).\n",
profileName, cfg.EntraEnroll.PeerID,
cfg.EntraEnroll.EnrolledAt.Format(time.RFC3339))
if !entraForce {
cmd.Println("Pass --force to re-enrol.")
return true, nil
}
cmd.Println("--force supplied; re-enrolling.")
return false, nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/cmd/entra_enroll.go` around lines 156 - 163, The code prints "Pass
--force to re-enrol." even when the user provided --force; move that hint inside
the non-force branch. In the block that checks an existing enrollment (using
profileName and cfg.EntraEnroll.*), only call cmd.Println("Pass --force to
re-enrol.") when entraForce is false (if !entraForce { cmd.Println(...); return
true, nil }), otherwise skip the hint and proceed to the existing return (return
false, nil).

}

// derivedWGPubKey returns the base64 WireGuard public key derived from the
// profile's stored private key.
func derivedWGPubKey(cfg *profilemanager.Config) (string, error) {
privKey, err := wgtypes.ParseKey(cfg.PrivateKey)
if err != nil {
return "", fmt.Errorf("parse profile WG private key: %w", err)
}
return privKey.PublicKey().String(), nil
}

// performEntraEnrolment loads the PFX, constructs the Enroller, and runs the
// HTTP round-trip. Structured server errors surface their stable code.
func performEntraEnrolment(ctx context.Context, cmd *cobra.Command, pfxPassword, wgPub string) (*entradevice.EntraEnrollState, error) {
cmd.Printf("Loading device certificate from %s\n", entraPFXPath)
cert, err := entradevice.LoadPFX(entraPFXPath, pfxPassword)
if err != nil {
return nil, fmt.Errorf("load pfx: %w", err)
}
deviceID, _ := cert.DeviceID()
cmd.Printf("Device identity: %s\n", deviceID)

en := &entradevice.Enroller{
BaseURL: strings.TrimSuffix(managementURL, entradevice.EnrolmentPathSuffix),
Cert: cert,
TenantID: entraTenantID,
WGPubKey: wgPub,
Hostname: entraHostname,
}
cmd.Printf("Enrolling against %s (tenant %s)\n", en.BaseURL+entradevice.EnrolmentPathSuffix, entraTenantID)

state, err := en.Enrol(ctx)
if err != nil {
if structured, ok := err.(*entradevice.Error); ok {
cmd.PrintErrf("Enrolment rejected: %s (HTTP %d)\n %s\n",
structured.Code, structured.HTTPStatus, structured.Message)
return nil, fmt.Errorf("enrolment failed: %s", structured.Code)
}
return nil, fmt.Errorf("enrolment failed: %w", err)
}
return state, nil
}

// persistEnrolmentState strips /join/entra from the saved ManagementURL,
// copies the response fields into the profile config, and writes it out.
func persistEnrolmentState(ctx context.Context, cfg *profilemanager.Config, configPath string, state *entradevice.EntraEnrollState) (string, error) {
cleanMgmt := strings.TrimSuffix(managementURL, entradevice.EnrolmentPathSuffix)
if cleanURL, err := url.Parse(cleanMgmt); err == nil {
cfg.ManagementURL = cleanURL
}
cfg.EntraEnroll = &profilemanager.EntraEnrollState{
EntraDeviceID: state.EntraDeviceID,
TenantID: state.TenantID,
PeerID: state.PeerID,
EnrolledAt: state.EnrolledAt,
EnrolledViaURL: state.EnrolledViaURL,
ResolutionMode: state.ResolutionMode,
ResolvedAutoGroups: state.ResolvedAutoGroups,
MatchedMappingIDs: state.MatchedMappingIDs,
}
if err := util.WriteJson(ctx, configPath, cfg); err != nil {
return "", fmt.Errorf("persist profile config: %w", err)
}
return cleanMgmt, nil
}

// printEnrolmentSuccess writes the human-readable success banner.
func printEnrolmentSuccess(cmd *cobra.Command, profileName string, state *entradevice.EntraEnrollState, cleanMgmt string) {
cmd.Println()
cmd.Println("========== ENROLMENT SUCCESS ==========")
cmd.Printf(" Profile : %s\n", profileName)
cmd.Printf(" Peer ID : %s\n", state.PeerID)
cmd.Printf(" Entra device id : %s\n", state.EntraDeviceID)
cmd.Printf(" Tenant id : %s\n", state.TenantID)
cmd.Printf(" Resolution mode : %s\n", state.ResolutionMode)
cmd.Printf(" Matched mapping(s) : %v\n", state.MatchedMappingIDs)
cmd.Printf(" Resolved auto-groups : %v\n", state.ResolvedAutoGroups)
cmd.Printf(" Management URL (saved) : %s\n", cleanMgmt)
cmd.Println()
cmd.Println(" Run 'netbird up' to bring the peer online.")
cmd.Println("=========================================")
}

var entraForce bool

func validateEntraFlags() error {
if entraPFXPath == "" {
return fmt.Errorf("--entra-pfx is required")
}
if entraTenantID == "" {
return fmt.Errorf("--entra-tenant is required")
}
return nil
}

func resolvePFXPassword() (string, error) {
if entraPFXPassword != "" {
return entraPFXPassword, nil
}
if entraPFXPassEnv != "" {
v := os.Getenv(entraPFXPassEnv)
if v == "" {
return "", fmt.Errorf("--entra-pfx-password-env %s is unset or empty", entraPFXPassEnv)
}
return v, nil
}
// Unprotected PFX — uncommon, but allowed.
return "", nil
}

// directLoadOrCreateProfileConfig bypasses util.WriteJsonWithRestrictedPermission
// (which fails on dev boxes without admin) and writes the config file with plain
// JSON + restrictive mode bits. Only used as a fallback when the normal path
// returns an ACL error.
func directLoadOrCreateProfileConfig(configPath, managementURL string) (*profilemanager.Config, error) {
if _, err := os.Stat(configPath); err == nil {
cfg := &profilemanager.Config{}
if _, err := util.ReadJson(configPath, cfg); err != nil {
return nil, fmt.Errorf("read existing config: %w", err)
}
return cfg, nil
}

// Use in-memory constructor to get a pristine Config with WG/SSH keys,
// then write it via the non-ACL-enforcing util.WriteJson.
cfg, err := profilemanager.CreateInMemoryConfig(profilemanager.ConfigInput{
ManagementURL: managementURL,
ConfigPath: configPath,
})
if err != nil {
return nil, fmt.Errorf("create in-memory config: %w", err)
}
if err := os.MkdirAll(filepathDir(configPath), 0o755); err != nil {
return nil, fmt.Errorf("mkdir %s: %w", configPath, err)
}
if err := util.WriteJson(context.Background(), configPath, cfg); err != nil {
return nil, fmt.Errorf("write config: %w", err)
}
return cfg, nil
}

func filepathDir(p string) string {
for i := len(p) - 1; i >= 0; i-- {
if p[i] == '\\' || p[i] == '/' {
return p[:i]
}
}
return "."
}

func init() {
entraEnrollCmd.Flags().StringVar(&entraPFXPath, "entra-pfx", "",
"Path to the PKCS#12 (.pfx) file containing the device certificate + private key. "+
"Deploy this via an Intune PKCS Certificate profile (supports Windows + macOS). "+
"Cert-store + TPM-backed signing is a planned follow-up.")
entraEnrollCmd.Flags().StringVar(&entraPFXPassword, "entra-pfx-password", "",
"Password for the PFX file (prefer --entra-pfx-password-env to avoid leaking it via ps/history)")
entraEnrollCmd.Flags().StringVar(&entraPFXPassEnv, "entra-pfx-password-env", "NB_ENTRA_PFX_PASSWORD",
"Name of the environment variable holding the PFX password")
entraEnrollCmd.Flags().StringVar(&entraTenantID, "entra-tenant", "",
"Entra tenant id the management server has an integration configured for")
entraEnrollCmd.Flags().StringVar(&entraHostname, "entra-hostname", "",
"Hostname to present to the server (defaults to 'entra-<device-id>')")
entraEnrollCmd.Flags().BoolVar(&entraForce, "force", false,
"Re-enrol even if this profile already has a persisted EntraEnrollState")
}
1 change: 1 addition & 0 deletions client/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ func init() {
rootCmd.AddCommand(debugCmd)
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(exposeCmd)
rootCmd.AddCommand(entraEnrollCmd)

networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
Expand Down
Loading