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 }}' >