Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions changes/41379-orbit-eua
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Orbit passes EUA token during enrollment request
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This should be in orbit/changes/ directory

10 changes: 10 additions & 0 deletions client/orbit_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
95 changes: 95 additions & 0 deletions client/orbit_client_eua_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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"

t.Run("eua_token included in enroll request when set", func(t *testing.T) {
var receivedBody fleet.EnrollOrbitRequest

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.NoError(t, json.Unmarshal(body, &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()
Comment on lines +24 to +34
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit. This setup is the same in both subtests. Can this be table-driven?


oc := &OrbitClient{
enrollSecret: "secret",
hostInfo: fleet.OrbitHostInfo{HardwareUUID: "uuid-1", Platform: "windows"},
euaToken: euaTokenValue,
}
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, euaTokenValue, receivedBody.EUAToken)
require.Equal(t, "secret", receivedBody.EnrollSecret)
require.Equal(t, "uuid-1", receivedBody.HardwareUUID)
})

t.Run("eua_token omitted from enroll request when empty", func(t *testing.T) {
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)

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"},
// euaToken not set — should be omitted from JSON (omitempty)
}
bc, err := NewBaseClient(srv.URL, true, "", "", nil, fleet.CapabilityMap{}, nil)
require.NoError(t, err)
oc.BaseClient = bc

_, err = oc.enroll()
require.NoError(t, err)

// Verify the eua_token key is not present in the JSON body.
require.Falsef(t, bytes.Contains(rawBody, []byte(`"eua_token"`)),
"eua_token should not appear in JSON when empty, got: %s", string(rawBody))
})
}

func TestSetEUAToken(t *testing.T) {
oc := &OrbitClient{}
require.Empty(t, oc.euaToken)

oc.SetEUAToken("some-token")
require.Equal(t, "some-token", oc.euaToken)

oc.SetEUAToken("")
require.Empty(t, oc.euaToken)
}
Comment on lines +86 to +95
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit. This is already covered by the test above.

12 changes: 12 additions & 0 deletions orbit/cmd/orbit/orbit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions orbit/pkg/packaging/packaging.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions orbit/pkg/packaging/windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
67 changes: 67 additions & 0 deletions orbit/pkg/packaging/windows_eua_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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, `<Property Id="EUA_TOKEN" Value="dummy"/>`)
assert.Contains(t, output, `--eua-token="[EUA_TOKEN]"`)
})

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`)
})

t.Run("EUA_TOKEN flag appears in ServiceInstall Arguments", func(t *testing.T) {
opt := baseOpt
opt.EnableEUATokenProperty = true

var buf bytes.Buffer
err := windowsWixTemplate.Execute(&buf, opt)
require.NoError(t, err)

// Find the ServiceInstall Arguments line and verify eua-token is in it.
for line := range strings.SplitSeq(buf.String(), "\n") {
if strings.Contains(line, "Arguments=") && strings.Contains(line, "--fleet-url") {
assert.Contains(t, line, `--eua-token="[EUA_TOKEN]"`,
"eua-token flag should be in ServiceInstall Arguments")
return
}
}
Comment on lines +49 to +64
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit. This could be merged with test 1.

t.Fatal("ServiceInstall Arguments line not found in template output")
})
}
7 changes: 6 additions & 1 deletion orbit/pkg/packaging/windows_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
<Property Id="EUA_TOKEN" Value="dummy"/>
{{ $euaTokenArg = " --eua-token=\"[EUA_TOKEN]\"" }}
{{ end }}

<MediaTemplate EmbedCab="yes" />

Expand Down Expand Up @@ -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 }}'
>
<util:ServiceConfig
FirstFailureActionType="restart"
Expand Down
Loading