diff --git a/client/orbit_client.go b/client/orbit_client.go
index c1bdfdf42b8..ae0e563eb3d 100644
--- a/client/orbit_client.go
+++ b/client/orbit_client.go
@@ -61,6 +61,10 @@ type OrbitClient struct {
// receiverUpdateCancelFunc is used to cancel receiverUpdateContext.
receiverUpdateCancelFunc context.CancelFunc
+ // euaToken is a one-time Fleet-signed JWT from Windows MDM enrollment,
+ // sent during orbit enrollment to link the IdP account without prompting.
+ euaToken string
+
// hostIdentityCertPath is the file path to the host identity certificate issued using SCEP.
//
// If set then it will be deleted on HTTP 401 errors from Fleet and it will cause ExecuteConfigReceivers
@@ -211,6 +215,11 @@ func NewOrbitClient(
}, nil
}
+// SetEUAToken sets a one-time EUA token to include in the enrollment request.
+func (oc *OrbitClient) SetEUAToken(token string) {
+ oc.euaToken = token
+}
+
// TriggerOrbitRestart triggers a orbit process restart.
func (oc *OrbitClient) TriggerOrbitRestart(reason string) {
log.Info().Msgf("orbit restart triggered: %s", reason)
@@ -512,6 +521,7 @@ func (oc *OrbitClient) enroll() (string, error) {
OsqueryIdentifier: oc.hostInfo.OsqueryIdentifier,
ComputerName: oc.hostInfo.ComputerName,
HardwareModel: oc.hostInfo.HardwareModel,
+ EUAToken: oc.euaToken,
}
var resp fleet.EnrollOrbitResponse
err := oc.request(verb, path, params, &resp)
diff --git a/client/orbit_client_eua_test.go b/client/orbit_client_eua_test.go
new file mode 100644
index 00000000000..b3e8343ae2a
--- /dev/null
+++ b/client/orbit_client_eua_test.go
@@ -0,0 +1,80 @@
+package client
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEnrollSendsEUAToken(t *testing.T) {
+ // nolint:gosec // not a real credential, test-only JWT fragment
+ euaTokenValue := "eyJhbGciOiJSUzI1NiJ9.test-eua-token"
+ const testNodeKey = "test-node-key-abc"
+
+ testCases := []struct {
+ name string
+ token string
+ assert func(t *testing.T, receivedBody fleet.EnrollOrbitRequest, rawBody []byte)
+ }{
+ {
+ name: "eua_token included in enroll request when set",
+ token: euaTokenValue,
+ assert: func(t *testing.T, receivedBody fleet.EnrollOrbitRequest, rawBody []byte) {
+ require.Equal(t, euaTokenValue, receivedBody.EUAToken)
+ },
+ },
+ {
+ name: "eua_token omitted from enroll request when empty",
+ token: "",
+ assert: func(t *testing.T, receivedBody fleet.EnrollOrbitRequest, rawBody []byte) {
+ // Verify the eua_token key is not present in the JSON body (omitempty).
+ require.Falsef(t, bytes.Contains(rawBody, []byte(`"eua_token"`)),
+ "eua_token should not appear in JSON when empty, got: %s", string(rawBody))
+ },
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ var receivedBody fleet.EnrollOrbitRequest
+ var rawBody []byte
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var err error
+ rawBody, err = io.ReadAll(r.Body)
+ assert.NoError(t, err)
+ assert.NoError(t, json.Unmarshal(rawBody, &receivedBody))
+
+ resp := fleet.EnrollOrbitResponse{OrbitNodeKey: testNodeKey}
+ w.Header().Set("Content-Type", "application/json")
+ err = json.NewEncoder(w).Encode(resp)
+ assert.NoError(t, err)
+ }))
+ defer srv.Close()
+
+ oc := &OrbitClient{
+ enrollSecret: "secret",
+ hostInfo: fleet.OrbitHostInfo{HardwareUUID: "uuid-1", Platform: "windows"},
+ }
+ oc.SetEUAToken(tc.token)
+ bc, err := NewBaseClient(srv.URL, true, "", "", nil, fleet.CapabilityMap{}, nil)
+ require.NoError(t, err)
+ oc.BaseClient = bc
+
+ nodeKey, err := oc.enroll()
+ require.NoError(t, err)
+ require.Equal(t, testNodeKey, nodeKey)
+ require.Equal(t, "secret", receivedBody.EnrollSecret)
+ require.Equal(t, "uuid-1", receivedBody.HardwareUUID)
+
+ tc.assert(t, receivedBody, rawBody)
+ })
+ }
+}
diff --git a/orbit/changes/41379-orbit-eua b/orbit/changes/41379-orbit-eua
new file mode 100644
index 00000000000..e9a0f11250c
--- /dev/null
+++ b/orbit/changes/41379-orbit-eua
@@ -0,0 +1 @@
+* Orbit passes EUA token during enrollment request
\ No newline at end of file
diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go
index 7f942b2fb96..ed6f10d07fb 100644
--- a/orbit/cmd/orbit/orbit.go
+++ b/orbit/cmd/orbit/orbit.go
@@ -228,6 +228,12 @@ func main() {
Usage: "Sets the email address of the user associated with the host when enrolling to Fleet. (requires Fleet >= v4.43.0)",
EnvVars: []string{"ORBIT_END_USER_EMAIL"},
},
+ &cli.StringFlag{
+ Name: "eua-token",
+ Hidden: true,
+ Usage: "EUA token from Windows MDM enrollment, used during orbit enrollment to link IdP account",
+ EnvVars: []string{"ORBIT_EUA_TOKEN"},
+ },
&cli.BoolFlag{
Name: "disable-keystore",
Usage: "Disables the use of the keychain on macOS and Credentials Manager on Windows",
@@ -1150,6 +1156,12 @@ func orbitAction(c *cli.Context) error {
return nil
})
+ // Set the EUA token from the MSI installer (Windows MDM enrollment).
+ // Must be set before any authenticated request triggers enrollment.
+ if euaToken := c.String("eua-token"); euaToken != "" && euaToken != unusedFlagKeyword {
+ orbitClient.SetEUAToken(euaToken)
+ }
+
// If the server can't be reached, we want to fail quickly on any blocking network calls
// so that desktop can be launched as soon as possible.
serverIsReachable := orbitClient.Ping() == nil
diff --git a/orbit/pkg/packaging/packaging.go b/orbit/pkg/packaging/packaging.go
index cd81341f6d0..31cbfcfde00 100644
--- a/orbit/pkg/packaging/packaging.go
+++ b/orbit/pkg/packaging/packaging.go
@@ -128,6 +128,8 @@ type Options struct {
// EndUserEmail is the email address of the end user that uses the host on
// which the agent is going to be installed.
EndUserEmail string
+ // EnableEUATokenProperty is a boolean indicating whether to enable EUA_TOKEN property in Windows MSI package.
+ EnableEUATokenProperty bool
// DisableKeystore disables the use of the keychain on macOS and Credentials Manager on Windows
DisableKeystore bool
// OsqueryDB is the directory to use for the osquery database.
diff --git a/orbit/pkg/packaging/windows.go b/orbit/pkg/packaging/windows.go
index d236b45f662..ebf7f4368b8 100644
--- a/orbit/pkg/packaging/windows.go
+++ b/orbit/pkg/packaging/windows.go
@@ -104,6 +104,10 @@ func BuildMSI(opt Options) (string, error) {
if semver.Compare(orbitVersion, "v1.28.0") >= 0 {
opt.EnableEndUserEmailProperty = true
}
+ // v1.55.0 introduced EUA_TOKEN property for MSI package: https://github.com/fleetdm/fleet/issues/41379
+ if semver.Compare(orbitVersion, "v1.55.0") >= 0 {
+ opt.EnableEUATokenProperty = true
+ }
// Write files
diff --git a/orbit/pkg/packaging/windows_eua_test.go b/orbit/pkg/packaging/windows_eua_test.go
new file mode 100644
index 00000000000..e81528dcf36
--- /dev/null
+++ b/orbit/pkg/packaging/windows_eua_test.go
@@ -0,0 +1,58 @@
+package packaging
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWindowsWixTemplateEUAToken(t *testing.T) {
+ baseOpt := Options{
+ FleetURL: "https://fleet.example.com",
+ EnrollSecret: "secret",
+ OrbitChannel: "stable",
+ OsquerydChannel: "stable",
+ DesktopChannel: "stable",
+ NativePlatform: "windows",
+ Architecture: ArchAmd64,
+ }
+
+ t.Run("EUA_TOKEN property and flag included when enabled", func(t *testing.T) {
+ opt := baseOpt
+ opt.EnableEUATokenProperty = true
+
+ var buf bytes.Buffer
+ err := windowsWixTemplate.Execute(&buf, opt)
+ require.NoError(t, err)
+
+ output := buf.String()
+ assert.Contains(t, output, ``)
+
+ var argsLine string
+ for line := range strings.SplitSeq(output, "\n") {
+ if strings.Contains(line, "Arguments=") && strings.Contains(line, "--fleet-url") {
+ argsLine = line
+ break
+ }
+ }
+ require.NotEmpty(t, argsLine, "ServiceInstall Arguments line not found in template output")
+ assert.Contains(t, argsLine, `--eua-token="[EUA_TOKEN]"`,
+ "eua-token flag should be in ServiceInstall Arguments")
+ })
+
+ t.Run("EUA_TOKEN property and flag absent when disabled", func(t *testing.T) {
+ opt := baseOpt
+ opt.EnableEUATokenProperty = false
+
+ var buf bytes.Buffer
+ err := windowsWixTemplate.Execute(&buf, opt)
+ require.NoError(t, err)
+
+ output := buf.String()
+ assert.NotContains(t, output, `EUA_TOKEN`)
+ assert.NotContains(t, output, `--eua-token`)
+ })
+}
diff --git a/orbit/pkg/packaging/windows_templates.go b/orbit/pkg/packaging/windows_templates.go
index 654b1461483..fe89a35a488 100644
--- a/orbit/pkg/packaging/windows_templates.go
+++ b/orbit/pkg/packaging/windows_templates.go
@@ -66,6 +66,11 @@ var windowsWixTemplate = template.Must(template.New("").Option("missingkey=error
{{ else if .EndUserEmail }}
{{ $endUserEmailArg = printf " --end-user-email \"%s\"" .EndUserEmail }}
{{ end }}
+ {{ $euaTokenArg := "" }}
+ {{ if .EnableEUATokenProperty }}
+
+ {{ $euaTokenArg = " --eua-token=\"[EUA_TOKEN]\"" }}
+ {{ end }}
@@ -109,7 +114,7 @@ var windowsWixTemplate = template.Must(template.New("").Option("missingkey=error
Start="auto"
Type="ownProcess"
Description="This service runs Fleet's osquery runtime and autoupdater (Orbit)."
- Arguments='--root-dir "[ORBITROOT]." --log-file "[System64Folder]config\systemprofile\AppData\Local\FleetDM\Orbit\Logs\orbit-osquery.log" --fleet-url "[FLEET_URL]"{{ if .FleetCertificate }} --fleet-certificate "[ORBITROOT]fleet.pem"{{ end }}{{ if .EnrollSecret }} --enroll-secret-path "[ORBITROOT]secret.txt"{{ end }}{{if .Insecure }} --insecure{{ end }}{{ if .Debug }} --debug{{ end }}{{ if .UpdateURL }} --update-url "{{ .UpdateURL }}"{{ end }}{{ if .UpdateTLSServerCertificate }} --update-tls-certificate "[ORBITROOT]update.pem"{{ end }}{{ if .DisableUpdates }} --disable-updates{{ end }} --fleet-desktop="[FLEET_DESKTOP]" --desktop-channel {{ .DesktopChannel }}{{ if .FleetDesktopAlternativeBrowserHost }} --fleet-desktop-alternative-browser-host {{ .FleetDesktopAlternativeBrowserHost }}{{ end }} --orbit-channel "{{ .OrbitChannel }}" --osqueryd-channel "{{ .OsquerydChannel }}" --enable-scripts="[ENABLE_SCRIPTS]" {{ if and (ne .HostIdentifier "") (ne .HostIdentifier "uuid") }}--host-identifier={{ .HostIdentifier }}{{ end }}{{ $endUserEmailArg }}{{ if .OsqueryDB }} --osquery-db="{{ .OsqueryDB }}"{{ end }}{{ if .DisableSetupExperience }} --disable-setup-experience{{ end }}'
+ Arguments='--root-dir "[ORBITROOT]." --log-file "[System64Folder]config\systemprofile\AppData\Local\FleetDM\Orbit\Logs\orbit-osquery.log" --fleet-url "[FLEET_URL]"{{ if .FleetCertificate }} --fleet-certificate "[ORBITROOT]fleet.pem"{{ end }}{{ if .EnrollSecret }} --enroll-secret-path "[ORBITROOT]secret.txt"{{ end }}{{if .Insecure }} --insecure{{ end }}{{ if .Debug }} --debug{{ end }}{{ if .UpdateURL }} --update-url "{{ .UpdateURL }}"{{ end }}{{ if .UpdateTLSServerCertificate }} --update-tls-certificate "[ORBITROOT]update.pem"{{ end }}{{ if .DisableUpdates }} --disable-updates{{ end }} --fleet-desktop="[FLEET_DESKTOP]" --desktop-channel {{ .DesktopChannel }}{{ if .FleetDesktopAlternativeBrowserHost }} --fleet-desktop-alternative-browser-host {{ .FleetDesktopAlternativeBrowserHost }}{{ end }} --orbit-channel "{{ .OrbitChannel }}" --osqueryd-channel "{{ .OsquerydChannel }}" --enable-scripts="[ENABLE_SCRIPTS]" {{ if and (ne .HostIdentifier "") (ne .HostIdentifier "uuid") }}--host-identifier={{ .HostIdentifier }}{{ end }}{{ $endUserEmailArg }}{{ $euaTokenArg }}{{ if .OsqueryDB }} --osquery-db="{{ .OsqueryDB }}"{{ end }}{{ if .DisableSetupExperience }} --disable-setup-experience{{ end }}'
>