From 42c9d37866d1a92973d87748321119bcbb9b303b Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Wed, 8 Apr 2026 16:01:45 -0500 Subject: [PATCH 01/15] Added EUA to the Fleet MSI installer --- changes/41381-eua-ms-installer | 1 + server/fleet/api_orbit.go | 2 + server/fleet/service.go | 2 +- server/mdm/microsoft/wstep.go | 80 +++++++++++++++++++++++++- server/mdm/microsoft/wstep_test.go | 44 ++++++++++++++ server/mock/service/service_mock.go | 6 +- server/service/microsoft_mdm.go | 29 +++++++++- server/service/orbit.go | 89 ++++++++++++++++++++++++++++- 8 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 changes/41381-eua-ms-installer diff --git a/changes/41381-eua-ms-installer b/changes/41381-eua-ms-installer new file mode 100644 index 00000000000..4408776558d --- /dev/null +++ b/changes/41381-eua-ms-installer @@ -0,0 +1 @@ +* Added support for passing end-user authentication context to the Fleet MSI installer during Windows MDM enrollment, so end users are not prompted to authenticate twice when EUA is enabled. \ No newline at end of file diff --git a/server/fleet/api_orbit.go b/server/fleet/api_orbit.go index 7a4610f274a..8acddec7650 100644 --- a/server/fleet/api_orbit.go +++ b/server/fleet/api_orbit.go @@ -32,6 +32,8 @@ type EnrollOrbitRequest struct { ComputerName string `json:"computer_name"` // HardwareModel is the device's hardware model. HardwareModel string `json:"hardware_model"` + // EUAToken is a Fleet-signed JWT containing the user's UPN and Windows MDM device ID. + EUAToken string `json:"eua_token,omitempty"` } // SetOrbitNodeKeyer is the interface implemented by orbit request types that diff --git a/server/fleet/service.go b/server/fleet/service.go index c653489aff9..da3c3df32b4 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -122,7 +122,7 @@ type Service interface { // // - If an entry for the host exists (osquery enrolled first) then it will update the host's orbit node key and team. // - If an entry for the host doesn't exist (osquery enrolls later) then it will create a new entry in the hosts table. - EnrollOrbit(ctx context.Context, hostInfo OrbitHostInfo, enrollSecret string) (orbitNodeKey string, err error) + EnrollOrbit(ctx context.Context, hostInfo OrbitHostInfo, enrollSecret string, euaToken string) (orbitNodeKey string, err error) // GetOrbitConfig returns team specific flags and extensions in agent options // if the team id is not nil for host, otherwise it returns flags from global // agent options. It also returns any notifications that fleet wants to surface diff --git a/server/mdm/microsoft/wstep.go b/server/mdm/microsoft/wstep.go index ee2305cce5d..7ea7bb719af 100644 --- a/server/mdm/microsoft/wstep.go +++ b/server/mdm/microsoft/wstep.go @@ -45,9 +45,17 @@ type CertManager interface { // NewSTSAuthToken returns an STS auth token for the given UPN claim. NewSTSAuthToken(upn string) (string, error) + // NewSTSAuthTokenWithDeviceID returns an STS auth token for the given UPN and + // Windows MDM device ID claims. Used to pass end-user authentication context + // to the orbit installer so the user is not prompted twice. + NewSTSAuthTokenWithDeviceID(upn string, deviceID string) (string, error) + // GetSTSAuthTokenUPNClaim validates the given token and returns the UPN claim GetSTSAuthTokenUPNClaim(token string) (string, error) + // GetSTSAuthTokenClaims validates the given token and returns the UPN and device ID claims. + GetSTSAuthTokenClaims(token string) (upn string, deviceID string, err error) + // TODO: implement other methods as needed: // - verify certificate-device association // - certificate lifecycle management (e.g., renewal, revocation) @@ -62,7 +70,8 @@ type CertStore interface { } type STSClaims struct { - UPN string `json:"upn"` + UPN string `json:"upn"` + DeviceID string `json:"device_id,omitempty"` jwt.RegisteredClaims } @@ -186,8 +195,8 @@ func (m *manager) NewSTSAuthToken(upn string) (string, error) { // Create claims with upn field populated claims := STSClaims{ - upn, - jwt.RegisteredClaims{ + UPN: upn, + RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()), @@ -205,6 +214,71 @@ func (m *manager) NewSTSAuthToken(upn string) (string, error) { return signedToken, nil } +// NewSTSAuthTokenWithDeviceID returns an STS auth token for the given UPN and Windows MDM device ID claims. +func (m *manager) NewSTSAuthTokenWithDeviceID(upn string, deviceID string) (string, error) { + if m == nil { + return "", errors.New("windows mdm identity keypair was not configured") + } + + if m.identityCert == nil || m.identityPrivateKey == nil { + return "", errors.New("invalid identity certificate or private key") + } + + if len(upn) == 0 { + return "", errors.New("invalid upn field") + } + + claims := STSClaims{ + UPN: upn, + DeviceID: deviceID, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Subject: "STSAuthToken", + }, + } + + token := jwt.NewWithClaims(jwt.GetSigningMethod("RS256"), claims) + signedToken, err := token.SignedString(m.identityPrivateKey) + if err != nil { + return "", fmt.Errorf("failed to sign STS token: %w", err) + } + + return signedToken, nil +} + +// GetSTSAuthTokenClaims validates the given token and returns the UPN and device ID claims. +func (m *manager) GetSTSAuthTokenClaims(tokenStr string) (string, string, error) { + if m == nil { + return "", "", errors.New("windows mdm identity keypair was not configured") + } + + if m.identityCert == nil || m.identityPrivateKey == nil { + return "", "", errors.New("invalid identity certificate or private key") + } + + if len(tokenStr) == 0 { + return "", "", errors.New("invalid STS token") + } + + token, err := jwt.ParseWithClaims(tokenStr, &STSClaims{}, func(token *jwt.Token) (any, error) { + return m.identityCert.PublicKey, nil + }) + if err != nil { + return "", "", fmt.Errorf("there was an error parsing the STS token claims: %w", err) + } + + if claims, ok := token.Claims.(*STSClaims); ok && token.Valid { + if len(claims.UPN) == 0 { + return "", "", errors.New("issue with UPN token claim") + } + return claims.UPN, claims.DeviceID, nil + } + + return "", "", errors.New("issue with STS token validation") +} + // GetSTSAuthToken validates the given token and returns the UPN claim func (m *manager) GetSTSAuthTokenUPNClaim(tokenStr string) (string, error) { if m == nil { diff --git a/server/mdm/microsoft/wstep_test.go b/server/mdm/microsoft/wstep_test.go index d6e4da3e34a..cc5bf7b7bb6 100644 --- a/server/mdm/microsoft/wstep_test.go +++ b/server/mdm/microsoft/wstep_test.go @@ -99,6 +99,50 @@ func TestSTSTokenSigningAndVerification(t *testing.T) { require.ErrorContains(t, err, "invalid upn field") } +func TestSTSTokenWithDeviceID(t *testing.T) { + var store CertStore + cm, err := NewCertManager(store, testCert, testKey) + require.NoError(t, err) + + upn := "user@example.com" + deviceID := "test-device-id-123" + + // Generate token with device ID + token, err := cm.NewSTSAuthTokenWithDeviceID(upn, deviceID) + require.NoError(t, err) + require.NotEmpty(t, token) + + // Validate and extract both claims + gotUPN, gotDeviceID, err := cm.GetSTSAuthTokenClaims(token) + require.NoError(t, err) + require.Equal(t, upn, gotUPN) + require.Equal(t, deviceID, gotDeviceID) + + // Empty UPN is rejected + _, err = cm.NewSTSAuthTokenWithDeviceID("", deviceID) + require.ErrorContains(t, err, "invalid upn field") + + // Empty device ID is allowed (optional claim) + token, err = cm.NewSTSAuthTokenWithDeviceID(upn, "") + require.NoError(t, err) + gotUPN, gotDeviceID, err = cm.GetSTSAuthTokenClaims(token) + require.NoError(t, err) + require.Equal(t, upn, gotUPN) + require.Empty(t, gotDeviceID) + + // Token signed by NewSTSAuthToken (no device_id) is also valid via GetSTSAuthTokenClaims + oldToken, err := cm.NewSTSAuthToken(upn) + require.NoError(t, err) + gotUPN, gotDeviceID, err = cm.GetSTSAuthTokenClaims(oldToken) + require.NoError(t, err) + require.Equal(t, upn, gotUPN) + require.Empty(t, gotDeviceID) + + // Tampered token is rejected + _, _, err = cm.GetSTSAuthTokenClaims(token + "tampered") + require.Error(t, err) +} + func TestCertFingerprintHexStr(t *testing.T) { cases := []struct { name string diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 47065893b45..685cc9cec04 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -50,7 +50,7 @@ type GetTransparencyURLFunc func(ctx context.Context) (string, error) type AuthenticateOrbitHostFunc func(ctx context.Context, nodeKey string) (host *fleet.Host, debug bool, err error) -type EnrollOrbitFunc func(ctx context.Context, hostInfo fleet.OrbitHostInfo, enrollSecret string) (orbitNodeKey string, err error) +type EnrollOrbitFunc func(ctx context.Context, hostInfo fleet.OrbitHostInfo, enrollSecret string, euaToken string) (orbitNodeKey string, err error) type GetOrbitConfigFunc func(ctx context.Context) (fleet.OrbitConfig, error) @@ -2354,11 +2354,11 @@ func (s *Service) AuthenticateOrbitHost(ctx context.Context, nodeKey string) (ho return s.AuthenticateOrbitHostFunc(ctx, nodeKey) } -func (s *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInfo, enrollSecret string) (orbitNodeKey string, err error) { +func (s *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInfo, enrollSecret string, euaToken string) (orbitNodeKey string, err error) { s.mu.Lock() s.EnrollOrbitFuncInvoked = true s.mu.Unlock() - return s.EnrollOrbitFunc(ctx, hostInfo, enrollSecret) + return s.EnrollOrbitFunc(ctx, hostInfo, enrollSecret, euaToken) } func (s *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, error) { diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index c506c805fc7..53b450a9f46 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -1501,6 +1501,28 @@ func (svc *Service) isFleetdPresentOnDevice(ctx context.Context, deviceID string return true, nil } +// generateWindowsEUAToken returns a Fleet-signed EUA token for the given Windows +// MDM device ID if the device enrolled with a valid Azure UPN +func (svc *Service) generateWindowsEUAToken(ctx context.Context, deviceID string) string { + if svc.wstepCertManager == nil { + return "" + } + device, err := svc.ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID) + if err != nil { + svc.logger.WarnContext(ctx, "unable to fetch windows mdm enrollment for EUA token generation", "err", err, "device_id", deviceID) + return "" + } + if device == nil || !microsoft_mdm.IsValidUPN(device.MDMEnrollUserID) { + return "" + } + token, err := svc.wstepCertManager.NewSTSAuthTokenWithDeviceID(device.MDMEnrollUserID, deviceID) + if err != nil { + svc.logger.WarnContext(ctx, "unable to generate EUA token for fleetd install", "err", err, "device_id", deviceID) + return "" + } + return token +} + func (svc *Service) enqueueInstallFleetdCommand(ctx context.Context, deviceID string) error { secrets, err := svc.ds.GetEnrollSecrets(ctx, nil) if err != nil { @@ -1530,6 +1552,11 @@ func (svc *Service) enqueueInstallFleetdCommand(ctx context.Context, deviceID st addCommandUUID := uuid.NewString() execCommandUUID := uuid.NewString() + euaTokenArg := "" + if token := svc.generateWindowsEUAToken(ctx, deviceID); token != "" { + euaTokenArg = ` EUA_TOKEN="` + token + `"` + } + rawAddCmd := []byte(` ` + addCommandUUID + ` @@ -1562,7 +1589,7 @@ func (svc *Service) enqueueInstallFleetdCommand(ctx context.Context, deviceID st ` + fleetdMetadata.MSISha256 + ` - /quiet FLEET_URL="` + fleetURL + `" FLEET_SECRET="` + globalEnrollSecret + `" ENABLE_SCRIPTS="True" + /quiet FLEET_URL="` + fleetURL + `" FLEET_SECRET="` + globalEnrollSecret + `" ENABLE_SCRIPTS="True"` + euaTokenArg + ` 10 1 5 diff --git a/server/service/orbit.go b/server/service/orbit.go index 103ee328beb..a2229a72f6f 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -55,7 +55,7 @@ func enrollOrbitEndpoint(ctx context.Context, request interface{}, svc fleet.Ser OsqueryIdentifier: req.OsqueryIdentifier, ComputerName: req.ComputerName, HardwareModel: req.HardwareModel, - }, req.EnrollSecret) + }, req.EnrollSecret, req.EUAToken) if err != nil { return enrollOrbitResponse{fleet.EnrollOrbitResponse{Err: err}}, nil } @@ -89,8 +89,58 @@ func (svc *Service) AuthenticateOrbitHost(ctx context.Context, orbitNodeKey stri return host, svc.debugEnabledForHost(ctx, host.ID), nil } +// processWindowsEUAToken validates a Fleet-signed EUA token from the Windows MSI +// installer, links the user's IdP account to the host, and returns the UPN and +// device ID for use in post-enrollment steps. +func (svc *Service) processWindowsEUAToken(ctx context.Context, hostUUID string, euaToken string) (upn string, deviceID string, err error) { + upn, deviceID, tokenErr := svc.wstepCertManager.GetSTSAuthTokenClaims(euaToken) + if tokenErr != nil { + svc.logger.WarnContext(ctx, "EUA token validation failed, falling back to end user auth prompt", + "err", tokenErr, "host_uuid", hostUUID) + return "", "", fleet.NewOrbitIDPAuthRequiredError() + } + + device, err := svc.ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID) + if err != nil { + return "", "", ctxerr.Wrap(ctx, err, "getting windows mdm enrollment for EUA token") + } + if device == nil { + svc.logger.WarnContext(ctx, "EUA token device_id not found in windows mdm enrollments, falling back to end user auth prompt", + "device_id", deviceID, "host_uuid", hostUUID) + return "", "", fleet.NewOrbitIDPAuthRequiredError() + } + + if device.HostUUID == "" { + // Fetch or create the mdm_idp_accounts row for this email. + // Fetch first so we do not overwrite existing first/last names + // that may have been populated by SCIM provisioning. + acct, err := svc.ds.GetMDMIdPAccountByEmail(ctx, upn) + if err != nil && !fleet.IsNotFound(err) { + return "", "", ctxerr.Wrap(ctx, err, "getting mdm idp account by email for EUA token") + } + if fleet.IsNotFound(err) { + acct = &fleet.MDMIdPAccount{Email: upn, Username: upn} + if err := svc.ds.InsertMDMIdPAccount(ctx, acct); err != nil { + return "", "", ctxerr.Wrap(ctx, err, "inserting mdm idp account for EUA token") + } + // Re-fetch to get the UUID assigned by the DB. + acct, err = svc.ds.GetMDMIdPAccountByEmail(ctx, upn) + if err != nil { + return "", "", ctxerr.Wrap(ctx, err, "re-fetching mdm idp account after insert for EUA token") + } + } + + // Link the IdP account to this host UUID in host_mdm_idp_accounts. + if err := svc.ds.AssociateHostMDMIdPAccountDB(ctx, hostUUID, acct.UUID); err != nil { + return "", "", ctxerr.Wrap(ctx, err, "associating host with mdm idp account for EUA token") + } + } + + return upn, deviceID, nil +} + // EnrollOrbit enrolls an Orbit instance to Fleet and returns the orbit node key. -func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInfo, enrollSecret string) (string, error) { +func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInfo, enrollSecret string, euaToken string) (string, error) { // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) @@ -162,6 +212,8 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf isEndUserAuthRequired = team.Config.MDM.MacOSSetup.EnableEndUserAuthentication } + var euaDeviceID, euaUPN string + if isEndUserAuthRequired { if hostInfo.HardwareUUID == "" { return "", fleet.OrbitError{Message: "failed to get IdP account: hardware uuid is empty"} @@ -188,6 +240,16 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf svc.logger.ErrorContext(ctx, "!!! ERR_ALLOWING_UNAUTHENTICATED: host is not authenticated, but fleet could not determine whether orbit supports end-user authentication. proceeding with enrollment. !!! ", "host_uuid", hostInfo.HardwareUUID) } else if !mp.Has(fleet.CapabilityEndUserAuth) { svc.logger.WarnContext(ctx, "!!! ERR_ALLOWING_UNAUTHENTICATED: host is not authenticated, but connected with an orbit version that does not support end user authentication. proceeding with enrollment. !!! ", "host_uuid", hostInfo.HardwareUUID) + } else if platform == "windows" && euaToken != "" { + // A Windows host already authenticated during MDM enrollment and the + // EUA token was passed by the MSI installer. + upn, deviceID, err := svc.processWindowsEUAToken(ctx, hostInfo.HardwareUUID, euaToken) + if err != nil { + return "", err + } + euaUPN = upn + euaDeviceID = deviceID + // Continue enrollment — do not return END_USER_AUTH_REQUIRED. } else { // Otherwise report the unauthenticated host and let Orbit handle it (e.g. by prompting the user to authenticate). return "", fleet.NewOrbitIDPAuthRequiredError() @@ -221,6 +283,29 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf return "", fleet.OrbitError{Message: "failed to enroll " + err.Error()} } + if euaDeviceID != "" { + if _, err := svc.ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, host.UUID, euaDeviceID); err != nil { + svc.logger.ErrorContext(ctx, "failed to link windows mdm enrollment to orbit host via EUA token", + "err", err, "host_uuid", host.UUID, "device_id", euaDeviceID) + } + + scimUser, err := svc.ds.ScimUserByUserNameOrEmail(ctx, euaUPN, euaUPN) + if err != nil && !fleet.IsNotFound(err) && err != sql.ErrNoRows { + svc.logger.ErrorContext(ctx, "failed to find SCIM user for EUA token enrollment", + "err", err, "host_id", host.ID) + } else if err == nil && scimUser != nil { + if err := svc.ds.SetOrUpdateHostSCIMUserMapping(ctx, host.ID, scimUser.ID); err != nil { + svc.logger.ErrorContext(ctx, "failed to set SCIM user mapping for EUA token enrollment", + "err", err, "host_id", host.ID) + } + } else { + if err := svc.ds.DeleteHostSCIMUserMapping(ctx, host.ID); err != nil && !fleet.IsNotFound(err) { + svc.logger.ErrorContext(ctx, "failed to delete SCIM user mapping for EUA token enrollment", + "err", err, "host_id", host.ID) + } + } + } + // Associate the newly-enrolled host with a SCIM user if applicable. // Do this only for linux and windows devices, as macOS devices // are associated during MDM enrollment. From ccf93d3eb9ff4ab6b66d32247856c03f8dcca2cf Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Wed, 8 Apr 2026 16:42:18 -0500 Subject: [PATCH 02/15] co-pilot feedback --- server/service/orbit.go | 49 ++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/server/service/orbit.go b/server/service/orbit.go index a2229a72f6f..79596b689f5 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -93,6 +93,12 @@ func (svc *Service) AuthenticateOrbitHost(ctx context.Context, orbitNodeKey stri // installer, links the user's IdP account to the host, and returns the UPN and // device ID for use in post-enrollment steps. func (svc *Service) processWindowsEUAToken(ctx context.Context, hostUUID string, euaToken string) (upn string, deviceID string, err error) { + if svc.wstepCertManager == nil { + // Windows MDM is not configured on this server so the token cannot be validated. + // Fall back to prompting the user for authentication. + return "", "", fleet.NewOrbitIDPAuthRequiredError() + } + upn, deviceID, tokenErr := svc.wstepCertManager.GetSTSAuthTokenClaims(euaToken) if tokenErr != nil { svc.logger.WarnContext(ctx, "EUA token validation failed, falling back to end user auth prompt", @@ -102,13 +108,13 @@ func (svc *Service) processWindowsEUAToken(ctx context.Context, hostUUID string, device, err := svc.ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID) if err != nil { + if fleet.IsNotFound(err) { + svc.logger.WarnContext(ctx, "EUA token device_id not found in windows mdm enrollments, falling back to end user auth prompt", + "device_id", deviceID, "host_uuid", hostUUID) + return "", "", fleet.NewOrbitIDPAuthRequiredError() + } return "", "", ctxerr.Wrap(ctx, err, "getting windows mdm enrollment for EUA token") } - if device == nil { - svc.logger.WarnContext(ctx, "EUA token device_id not found in windows mdm enrollments, falling back to end user auth prompt", - "device_id", deviceID, "host_uuid", hostUUID) - return "", "", fleet.NewOrbitIDPAuthRequiredError() - } if device.HostUUID == "" { // Fetch or create the mdm_idp_accounts row for this email. @@ -128,6 +134,9 @@ func (svc *Service) processWindowsEUAToken(ctx context.Context, hostUUID string, if err != nil { return "", "", ctxerr.Wrap(ctx, err, "re-fetching mdm idp account after insert for EUA token") } + if acct == nil { + return "", "", ctxerr.New(ctx, "mdm idp account not found after insert for EUA token") + } } // Link the IdP account to this host UUID in host_mdm_idp_accounts. @@ -284,24 +293,28 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf } if euaDeviceID != "" { - if _, err := svc.ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, host.UUID, euaDeviceID); err != nil { + updated, err := svc.ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, host.UUID, euaDeviceID) + if err != nil { svc.logger.ErrorContext(ctx, "failed to link windows mdm enrollment to orbit host via EUA token", "err", err, "host_uuid", host.UUID, "device_id", euaDeviceID) } - scimUser, err := svc.ds.ScimUserByUserNameOrEmail(ctx, euaUPN, euaUPN) - if err != nil && !fleet.IsNotFound(err) && err != sql.ErrNoRows { - svc.logger.ErrorContext(ctx, "failed to find SCIM user for EUA token enrollment", - "err", err, "host_id", host.ID) - } else if err == nil && scimUser != nil { - if err := svc.ds.SetOrUpdateHostSCIMUserMapping(ctx, host.ID, scimUser.ID); err != nil { - svc.logger.ErrorContext(ctx, "failed to set SCIM user mapping for EUA token enrollment", - "err", err, "host_id", host.ID) - } - } else { - if err := svc.ds.DeleteHostSCIMUserMapping(ctx, host.ID); err != nil && !fleet.IsNotFound(err) { - svc.logger.ErrorContext(ctx, "failed to delete SCIM user mapping for EUA token enrollment", + if updated { + scimUser, err := svc.ds.ScimUserByUserNameOrEmail(ctx, euaUPN, euaUPN) + //nolint:gocritic // ignore ifElseChain + if err != nil && !fleet.IsNotFound(err) && err != sql.ErrNoRows { + svc.logger.ErrorContext(ctx, "failed to find SCIM user for EUA token enrollment", "err", err, "host_id", host.ID) + } else if err == nil && scimUser != nil { + if err := svc.ds.SetOrUpdateHostSCIMUserMapping(ctx, host.ID, scimUser.ID); err != nil { + svc.logger.ErrorContext(ctx, "failed to set SCIM user mapping for EUA token enrollment", + "err", err, "host_id", host.ID) + } + } else { + if err := svc.ds.DeleteHostSCIMUserMapping(ctx, host.ID); err != nil && !fleet.IsNotFound(err) { + svc.logger.ErrorContext(ctx, "failed to delete SCIM user mapping for EUA token enrollment", + "err", err, "host_id", host.ID) + } } } } From db1e67808ad339945e6b5c125f74600b3454e8f9 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Thu, 9 Apr 2026 09:14:54 -0500 Subject: [PATCH 03/15] feedback --- server/mdm/microsoft/wstep.go | 3 ++ server/mdm/microsoft/wstep_test.go | 10 ++----- server/service/orbit.go | 47 ++++++++++++++---------------- 3 files changed, 28 insertions(+), 32 deletions(-) diff --git a/server/mdm/microsoft/wstep.go b/server/mdm/microsoft/wstep.go index 7ea7bb719af..35f954b3efc 100644 --- a/server/mdm/microsoft/wstep.go +++ b/server/mdm/microsoft/wstep.go @@ -227,6 +227,9 @@ func (m *manager) NewSTSAuthTokenWithDeviceID(upn string, deviceID string) (stri if len(upn) == 0 { return "", errors.New("invalid upn field") } + if len(deviceID) == 0 { + return "", errors.New("invalid device_id field") + } claims := STSClaims{ UPN: upn, diff --git a/server/mdm/microsoft/wstep_test.go b/server/mdm/microsoft/wstep_test.go index cc5bf7b7bb6..e7af8ff95e3 100644 --- a/server/mdm/microsoft/wstep_test.go +++ b/server/mdm/microsoft/wstep_test.go @@ -122,13 +122,9 @@ func TestSTSTokenWithDeviceID(t *testing.T) { _, err = cm.NewSTSAuthTokenWithDeviceID("", deviceID) require.ErrorContains(t, err, "invalid upn field") - // Empty device ID is allowed (optional claim) - token, err = cm.NewSTSAuthTokenWithDeviceID(upn, "") - require.NoError(t, err) - gotUPN, gotDeviceID, err = cm.GetSTSAuthTokenClaims(token) - require.NoError(t, err) - require.Equal(t, upn, gotUPN) - require.Empty(t, gotDeviceID) + // Empty device ID is rejected + _, err = cm.NewSTSAuthTokenWithDeviceID(upn, "") + require.ErrorContains(t, err, "invalid device_id field") // Token signed by NewSTSAuthToken (no device_id) is also valid via GetSTSAuthTokenClaims oldToken, err := cm.NewSTSAuthToken(upn) diff --git a/server/service/orbit.go b/server/service/orbit.go index 79596b689f5..dc31fa1eaf9 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -106,7 +106,7 @@ func (svc *Service) processWindowsEUAToken(ctx context.Context, hostUUID string, return "", "", fleet.NewOrbitIDPAuthRequiredError() } - device, err := svc.ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID) + _, err = svc.ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID) if err != nil { if fleet.IsNotFound(err) { svc.logger.WarnContext(ctx, "EUA token device_id not found in windows mdm enrollments, falling back to end user auth prompt", @@ -116,33 +116,30 @@ func (svc *Service) processWindowsEUAToken(ctx context.Context, hostUUID string, return "", "", ctxerr.Wrap(ctx, err, "getting windows mdm enrollment for EUA token") } - if device.HostUUID == "" { - // Fetch or create the mdm_idp_accounts row for this email. - // Fetch first so we do not overwrite existing first/last names - // that may have been populated by SCIM provisioning. - acct, err := svc.ds.GetMDMIdPAccountByEmail(ctx, upn) - if err != nil && !fleet.IsNotFound(err) { - return "", "", ctxerr.Wrap(ctx, err, "getting mdm idp account by email for EUA token") + // Fetch or create the mdm_idp_accounts row for this email. + // Fetch first so we do not overwrite existing first/last names + // that may have been populated by SCIM provisioning. + acct, err := svc.ds.GetMDMIdPAccountByEmail(ctx, upn) + if err != nil && !fleet.IsNotFound(err) { + return "", "", ctxerr.Wrap(ctx, err, "getting mdm idp account by email for EUA token") + } + if fleet.IsNotFound(err) { + if err := svc.ds.InsertMDMIdPAccount(ctx, &fleet.MDMIdPAccount{Email: upn, Username: upn}); err != nil { + return "", "", ctxerr.Wrap(ctx, err, "inserting mdm idp account for EUA token") } - if fleet.IsNotFound(err) { - acct = &fleet.MDMIdPAccount{Email: upn, Username: upn} - if err := svc.ds.InsertMDMIdPAccount(ctx, acct); err != nil { - return "", "", ctxerr.Wrap(ctx, err, "inserting mdm idp account for EUA token") - } - // Re-fetch to get the UUID assigned by the DB. - acct, err = svc.ds.GetMDMIdPAccountByEmail(ctx, upn) - if err != nil { - return "", "", ctxerr.Wrap(ctx, err, "re-fetching mdm idp account after insert for EUA token") - } - if acct == nil { - return "", "", ctxerr.New(ctx, "mdm idp account not found after insert for EUA token") - } + // Re-fetch to get the UUID assigned by the DB. + acct, err = svc.ds.GetMDMIdPAccountByEmail(ctx, upn) + if err != nil { + return "", "", ctxerr.Wrap(ctx, err, "re-fetching mdm idp account after insert for EUA token") } + } + if acct == nil { + return "", "", ctxerr.New(ctx, "mdm idp account not found for EUA token") + } - // Link the IdP account to this host UUID in host_mdm_idp_accounts. - if err := svc.ds.AssociateHostMDMIdPAccountDB(ctx, hostUUID, acct.UUID); err != nil { - return "", "", ctxerr.Wrap(ctx, err, "associating host with mdm idp account for EUA token") - } + // Link the IdP account to this host UUID in host_mdm_idp_accounts. + if err := svc.ds.AssociateHostMDMIdPAccountDB(ctx, hostUUID, acct.UUID); err != nil { + return "", "", ctxerr.Wrap(ctx, err, "associating host with mdm idp account for EUA token") } return upn, deviceID, nil From 610ca7237c1cf5c8acab3129bcfa0bd27143036b Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Thu, 9 Apr 2026 10:08:21 -0500 Subject: [PATCH 04/15] feedback --- server/mdm/microsoft/wstep.go | 3 +++ server/mdm/microsoft/wstep_test.go | 8 +++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/server/mdm/microsoft/wstep.go b/server/mdm/microsoft/wstep.go index 35f954b3efc..8cb4c93dab3 100644 --- a/server/mdm/microsoft/wstep.go +++ b/server/mdm/microsoft/wstep.go @@ -276,6 +276,9 @@ func (m *manager) GetSTSAuthTokenClaims(tokenStr string) (string, string, error) if len(claims.UPN) == 0 { return "", "", errors.New("issue with UPN token claim") } + if len(claims.DeviceID) == 0 { + return "", "", errors.New("issue with device_id token claim") + } return claims.UPN, claims.DeviceID, nil } diff --git a/server/mdm/microsoft/wstep_test.go b/server/mdm/microsoft/wstep_test.go index e7af8ff95e3..af2daac28b5 100644 --- a/server/mdm/microsoft/wstep_test.go +++ b/server/mdm/microsoft/wstep_test.go @@ -126,13 +126,11 @@ func TestSTSTokenWithDeviceID(t *testing.T) { _, err = cm.NewSTSAuthTokenWithDeviceID(upn, "") require.ErrorContains(t, err, "invalid device_id field") - // Token signed by NewSTSAuthToken (no device_id) is also valid via GetSTSAuthTokenClaims + // Token signed by NewSTSAuthToken (no device_id) is rejected — device_id is required oldToken, err := cm.NewSTSAuthToken(upn) require.NoError(t, err) - gotUPN, gotDeviceID, err = cm.GetSTSAuthTokenClaims(oldToken) - require.NoError(t, err) - require.Equal(t, upn, gotUPN) - require.Empty(t, gotDeviceID) + _, _, err = cm.GetSTSAuthTokenClaims(oldToken) + require.ErrorContains(t, err, "issue with device_id token claim") // Tampered token is rejected _, _, err = cm.GetSTSAuthTokenClaims(token + "tampered") From 1d44608b52f02b98c5ca0194c583a2232a8165f5 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Thu, 9 Apr 2026 15:17:03 -0500 Subject: [PATCH 05/15] adding missing test file --- server/service/orbit_eua_test.go | 304 +++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 server/service/orbit_eua_test.go diff --git a/server/service/orbit_eua_test.go b/server/service/orbit_eua_test.go new file mode 100644 index 00000000000..7421f5192f8 --- /dev/null +++ b/server/service/orbit_eua_test.go @@ -0,0 +1,304 @@ +package service + +import ( + "context" + "database/sql" + "log/slog" + "strings" + "testing" + + "github.com/fleetdm/fleet/v4/server/fleet" + microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" + "github.com/fleetdm/fleet/v4/server/mock" + mysql_errors "github.com/fleetdm/fleet/v4/server/platform/mysql" + "github.com/stretchr/testify/require" +) + +// euaTestingKey replaces "TESTING KEY" with "PRIVATE KEY" to prevent secret +// scanners from flagging test keys embedded in source files. +func euaTestingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") } + +// testWSTEPCert and testWSTEPKey are the same certs used in wstep_test.go. +var ( + testWSTEPCert = []byte(`-----BEGIN CERTIFICATE----- +MIIDGzCCAgOgAwIBAgIBATANBgkqhkiG9w0BAQsFADAvMQkwBwYD +VQQGEwAxEDAOBgNVBAoTB3NjZXAtY2ExEDAOBgNVBAsTB1NDRVAg +Q0EwHhcNMjIxMjIyMTM0NDMzWhcNMzIxMjIyMTM0NDMzWjAvMQkw +BwYDVQQGEwAxEDAOBgNVBAoTB3NjZXAtY2ExEDAOBgNVBAsTB1ND +RVAgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDV +u9YVfl7gu0UgUkOJoES/XrN0WZdIjgvS2upKfvP4LSJOq1Mnp3bH +wWOA2NkHem/kjOVeotOk1aEYIzxbic6VlvNOz9huOhbJyoV4TO5v +tp/GFFcJ4IXh+f1Q4vm/NeH/XxEWn9S20B9OkSMOUievYsAu6iSi +oWaa74q1mnfpzM29p3dNM82mCKutYdkW0EusixU/CQxcVhdcxC+R +RyM4jzBFIipa7H20UtqdkZ03/9BoowJb/h/r4X7TN4tKg2vcwpZK +uJo7VcTBNPxhBowzg3JUmzjCnxPbuU/Ow5kPGOLJtbf4766ToNTM +/J63i3UPshKUBqAE8mIZO3qb7s25AgMBAAGjQjBAMA4GA1UdDwEB +/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTxPEY4 +WvsLCt+HDQfnEPOKrHu0gTANBgkqhkiG9w0BAQsFAAOCAQEAGNf5 +R60vRxIfvSOUyV3X7lUk+fVvi1CKC43DsP5OsQ6g5YVGcVXN40U4 +2o7JUeb9K1jvqnzWB/3k+lSCkEb0a5KabjZE5Vpdt9xctmgrfNnQ +PBCfDdyb0Upjm61CJeB2SW9+ibT2L+OtL/nZjjlugL7ir9ramQBh +0IY6oB9Yc3TyZyPjnXwbi0jv5cildzIYaYPvPkPPTjezOUqUDgUH +JtdWRBQeJ/6WxAAm9il0KVXOsRPgAsdiDJTF6FdW4lsY8V/R6y0H +hTN1ZSyqklKAuvEZZznfmJsrNYRII2Fv2zOk0Uv/+E+EKTOHbgcC +PQAARDBzDlWvlMGWcbdrdypdeA== +-----END CERTIFICATE----- +`) + + testWSTEPKey = []byte(euaTestingKey(`-----BEGIN RSA TESTING KEY----- +MIIEowIBAAKCAQEA1bvWFX5e4LtFIFJDiaBEv16zdFmXSI4L0trqSn7z+C0iTqtT +J6d2x8FjgNjZB3pv5IzlXqLTpNWhGCM8W4nOlZbzTs/YbjoWycqFeEzub7afxhRX +CeCF4fn9UOL5vzXh/18RFp/UttAfTpEjDlInr2LALuokoqFmmu+KtZp36czNvad3 +TTPNpgirrWHZFtBLrIsVPwkMXFYXXMQvkUcjOI8wRSIqWux9tFLanZGdN//QaKMC +W/4f6+F+0zeLSoNr3MKWSriaO1XEwTT8YQaMM4NyVJs4wp8T27lPzsOZDxjiybW3 ++O+uk6DUzPyet4t1D7ISlAagBPJiGTt6m+7NuQIDAQABAoIBAE6LXL1BV3SW3Wxn +TtKAx0Lcdm5HjkTnjojKUldWGCoXzAfFBiYIcKov83UiO394Cy6eaJxCkix9JVpN +eJzbI8PtWTSZRRwc1MsLVclD3EvJfSW5y9KhZBILYIAdKVKPZqIGOa1qxyz3hsnE +pHFa16KoU5/qA9SQI7jEVuEuBusv4D/dRlEWvva7QOhnLrBPrSnTSZ5LxCFKRviS +XrEQ9AuRJeXCKx4WzXd4IZPpgldYHMJSSGMr0TeVcURbsfveI2IWvOLag0ofTHhx +tolBT2sKzInItLTwt/irZEp5lV08mMGxHuxoCdzhxjFQP8eGOZzPW65c6/D9hEXd +DzWnjdECgYEA9QtTQosOTtAyU1i4Fm76ltT6nywHy23KAMhBaoKgTMccNtjaOCg/ +5FCCRD+qoo7TF4jdliP2NrMIbAIhr4jEfHSMKaD/rae1xqInseDCrGi9gzvm8UxG +84VG30Id8s70ZQWZjR/PFFDeNZjNhlk8COO0XoLaqJSZr+A30aSyeUsCgYEA30ok +3EvO1+/gjZv28J9vApdbiEwtO9xoteghElFzdtuEuzA+wL83w8xvKvdb4Rk5xigE +6mV69dBPj8zSyGp0lFTYLFvry5N4S8L6QPzt2nk+Lc3cDKSA5CkAkQ5Dmt5JwhxF +qIPDNZGXmoldIWJ0p/ZSu98/1yXBMQ9gCje/losCgYBwuk4KLbheT27nYsgFIfbL +zpyg/vty/UXRiE53tjISQALdxHLXJMUHvnW++d8Au12m1QLDIDYTQdddALoIa42g +h2k3eWZFuAJqp4xFS1WjROfx6Gu8k8+MFcLd0CfA3K4XjzTtdDWqbe1bkLjz1jdF +C6OdWutGZF4zR53GJtMn8wKBgCfA95cRGB5x4rTTk797YzQ+5lj51wPVVf8s+NZe +EgSTSKpbCJEgejkt6IzpxT3qU9LnxRhGQQIKuF+Nw+lSqrbN9D7RjsWL19sFN7Di +VyaSd3OINyk5EImOkz9AHuEvukoI5o3+B38+EJO+6QnMkaBlxo0UTjVrz12As0Se +cEnJAoGBAOUXjez9oUSzLzqG/WJFrIfHyjDA1vBS1j39XuhDuJGqMdNLlCE8Yr7h +d3gpZeuV3ZC33QAuwAXfRBNnKIDtDGpcrozM1NndcBVDs9GYvobaTiUaODGjsH44 +oHwpyQbv9Qs+3bjPOQ7DkwekT+w1cptEKudBCC3WQKui1P0NNL0R +-----END RSA TESTING KEY----- +`)) +) + +// newTestServiceWithWSTEP returns a Service with a real wstepCertManager built +// from the inline test cert/key, backed by a mock datastore. +func newTestServiceWithWSTEP(t *testing.T, ds *mock.Store) *Service { + t.Helper() + certManager, err := microsoft_mdm.NewCertManager(nil, testWSTEPCert, testWSTEPKey) + require.NoError(t, err) + + return &Service{ + ds: ds, + wstepCertManager: certManager, + logger: slog.New(slog.DiscardHandler), + } +} + +func TestProcessWindowsEUAToken(t *testing.T) { + const ( + testUPN = "user@example.com" + testDeviceID = "device-abc-123" + testHostUUID = "host-uuid-xyz" + testAcctUUID = "acct-uuid-456" + ) + + // Helper to generate a valid token for test cases. + makeToken := func(t *testing.T, svc *Service, upn, deviceID string) string { + t.Helper() + tok, err := svc.wstepCertManager.NewSTSAuthTokenWithDeviceID(upn, deviceID) + require.NoError(t, err) + return tok + } + + t.Run("valid token, new enrollment, account not yet in db", func(t *testing.T) { + ds := new(mock.Store) + svc := newTestServiceWithWSTEP(t, ds) + token := makeToken(t, svc, testUPN, testDeviceID) + + ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + require.Equal(t, testDeviceID, mdmDeviceID) + return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, HostUUID: ""}, nil + } + // First call returns not-found; second call (after insert) returns the account. + getByEmailCalls := 0 + ds.GetMDMIdPAccountByEmailFunc = func(ctx context.Context, email string) (*fleet.MDMIdPAccount, error) { + require.Equal(t, testUPN, email) + getByEmailCalls++ + if getByEmailCalls == 1 { + return nil, mysql_errors.NotFound("MDMIdPAccount") + } + return &fleet.MDMIdPAccount{UUID: testAcctUUID, Email: testUPN, Username: testUPN}, nil + } + ds.InsertMDMIdPAccountFunc = func(ctx context.Context, account *fleet.MDMIdPAccount) error { + require.Equal(t, testUPN, account.Email) + return nil + } + ds.AssociateHostMDMIdPAccountDBFunc = func(ctx context.Context, hostUUID, acctUUID string) error { + require.Equal(t, testHostUUID, hostUUID) + require.Equal(t, testAcctUUID, acctUUID) + return nil + } + + upn, deviceID, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, token) + require.NoError(t, err) + require.Equal(t, testUPN, upn) + require.Equal(t, testDeviceID, deviceID) + require.True(t, ds.AssociateHostMDMIdPAccountDBFuncInvoked) + }) + + t.Run("valid token, account already exists in db", func(t *testing.T) { + ds := new(mock.Store) + svc := newTestServiceWithWSTEP(t, ds) + token := makeToken(t, svc, testUPN, testDeviceID) + + ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, HostUUID: ""}, nil + } + // Account exists — Insert should NOT be called. + ds.GetMDMIdPAccountByEmailFunc = func(ctx context.Context, email string) (*fleet.MDMIdPAccount, error) { + return &fleet.MDMIdPAccount{UUID: testAcctUUID, Email: testUPN, Username: "existing-username", Fullname: "Existing Name"}, nil + } + ds.AssociateHostMDMIdPAccountDBFunc = func(ctx context.Context, hostUUID, acctUUID string) error { + return nil + } + + _, _, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, token) + require.NoError(t, err) + require.False(t, ds.InsertMDMIdPAccountFuncInvoked, "should not insert when account already exists") + require.True(t, ds.AssociateHostMDMIdPAccountDBFuncInvoked) + }) + + t.Run("valid token, enrollment already has host_uuid — still links idp account", func(t *testing.T) { + ds := new(mock.Store) + svc := newTestServiceWithWSTEP(t, ds) + token := makeToken(t, svc, testUPN, testDeviceID) + + ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + // HostUUID already set — device was previously enrolled. + return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, HostUUID: "existing-host-uuid"}, nil + } + // Account already exists — re-enrollment after host deletion may + // have left the enrollment row populated but the mapping missing. + ds.GetMDMIdPAccountByEmailFunc = func(ctx context.Context, email string) (*fleet.MDMIdPAccount, error) { + return &fleet.MDMIdPAccount{UUID: testAcctUUID, Email: testUPN, Username: testUPN}, nil + } + ds.AssociateHostMDMIdPAccountDBFunc = func(ctx context.Context, hostUUID, acctUUID string) error { + require.Equal(t, testHostUUID, hostUUID) + require.Equal(t, testAcctUUID, acctUUID) + return nil + } + + upn, deviceID, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, token) + require.NoError(t, err) + require.Equal(t, testUPN, upn) + require.Equal(t, testDeviceID, deviceID) + require.True(t, ds.GetMDMIdPAccountByEmailFuncInvoked, "should still fetch idp account even when enrollment has host_uuid") + require.True(t, ds.AssociateHostMDMIdPAccountDBFuncInvoked, "should still link idp account even when enrollment has host_uuid") + }) + + t.Run("expired or invalid token falls back to END_USER_AUTH_REQUIRED", func(t *testing.T) { + ds := new(mock.Store) + svc := newTestServiceWithWSTEP(t, ds) + + _, _, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, "this.is.not.a.valid.token") + require.Error(t, err) + var orbitErr *fleet.OrbitError + require.ErrorAs(t, err, &orbitErr) + require.Equal(t, "END_USER_AUTH_REQUIRED", orbitErr.Message) + require.False(t, ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked) + }) + + t.Run("nil wstepCertManager falls back to END_USER_AUTH_REQUIRED without panic", func(t *testing.T) { + ds := new(mock.Store) + svc := &Service{ds: ds, logger: slog.New(slog.DiscardHandler)} + + _, _, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, "any.token.value") + require.Error(t, err) + var orbitErr *fleet.OrbitError + require.ErrorAs(t, err, &orbitErr) + require.Equal(t, "END_USER_AUTH_REQUIRED", orbitErr.Message) + require.False(t, ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked) + }) + + t.Run("device not found falls back to END_USER_AUTH_REQUIRED", func(t *testing.T) { + ds := new(mock.Store) + svc := newTestServiceWithWSTEP(t, ds) + token := makeToken(t, svc, testUPN, testDeviceID) + + ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + return nil, mysql_errors.NotFound("MDMWindowsEnrolledDevice") + } + + _, _, err := svc.processWindowsEUAToken(context.Background(), testHostUUID, token) + require.Error(t, err) + var orbitErr *fleet.OrbitError + require.ErrorAs(t, err, &orbitErr) + require.Equal(t, "END_USER_AUTH_REQUIRED", orbitErr.Message) + }) +} + +func TestGenerateWindowsEUAToken(t *testing.T) { + const ( + testUPN = "user@example.com" + testDeviceID = "device-abc-123" + ) + + t.Run("returns token for device with valid UPN", func(t *testing.T) { + ds := new(mock.Store) + svc := newTestServiceWithWSTEP(t, ds) + + ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, MDMEnrollUserID: testUPN}, nil + } + + token := svc.generateWindowsEUAToken(context.Background(), testDeviceID) + require.NotEmpty(t, token) + + // Token should be valid and contain expected claims. + upn, deviceID, err := svc.wstepCertManager.GetSTSAuthTokenClaims(token) + require.NoError(t, err) + require.Equal(t, testUPN, upn) + require.Equal(t, testDeviceID, deviceID) + }) + + t.Run("returns empty string when device has no UPN", func(t *testing.T) { + ds := new(mock.Store) + svc := newTestServiceWithWSTEP(t, ds) + + ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, MDMEnrollUserID: ""}, nil + } + + require.Empty(t, svc.generateWindowsEUAToken(context.Background(), testDeviceID)) + }) + + t.Run("returns empty string when device not found", func(t *testing.T) { + ds := new(mock.Store) + svc := newTestServiceWithWSTEP(t, ds) + + ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + return nil, nil + } + + require.Empty(t, svc.generateWindowsEUAToken(context.Background(), testDeviceID)) + }) + + t.Run("returns empty string when datastore returns error", func(t *testing.T) { + ds := new(mock.Store) + svc := newTestServiceWithWSTEP(t, ds) + + ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + return nil, sql.ErrConnDone + } + + require.Empty(t, svc.generateWindowsEUAToken(context.Background(), testDeviceID)) + }) + + t.Run("returns empty string when wstepCertManager is nil", func(t *testing.T) { + ds := new(mock.Store) + svc := &Service{ds: ds, logger: slog.New(slog.DiscardHandler)} + + ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { + return &fleet.MDMWindowsEnrolledDevice{MDMDeviceID: testDeviceID, MDMEnrollUserID: testUPN}, nil + } + + require.Empty(t, svc.generateWindowsEUAToken(context.Background(), testDeviceID)) + require.False(t, ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked, "should not query db when cert manager is nil") + }) +} + From b141270d8c62829edb92219bc8e2ec727b702bae Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Thu, 9 Apr 2026 15:26:32 -0500 Subject: [PATCH 06/15] linting issue --- server/service/orbit_eua_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/server/service/orbit_eua_test.go b/server/service/orbit_eua_test.go index 7421f5192f8..e4fa1d954cf 100644 --- a/server/service/orbit_eua_test.go +++ b/server/service/orbit_eua_test.go @@ -301,4 +301,3 @@ func TestGenerateWindowsEUAToken(t *testing.T) { require.False(t, ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked, "should not query db when cert manager is nil") }) } - From 4523f34a47e588c78967f971c950491e6afbfc15 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Fri, 10 Apr 2026 10:59:37 -0500 Subject: [PATCH 07/15] separate sts with eua. other minor fixes --- server/mdm/microsoft/wstep.go | 60 +++++++++++++++----------- server/mdm/microsoft/wstep_test.go | 16 +++---- server/service/integration_mdm_test.go | 11 ++++- server/service/microsoft_mdm.go | 6 +-- server/service/orbit.go | 20 +++++---- server/service/orbit_eua_test.go | 12 +++--- 6 files changed, 73 insertions(+), 52 deletions(-) diff --git a/server/mdm/microsoft/wstep.go b/server/mdm/microsoft/wstep.go index 8cb4c93dab3..663ebdfb0ed 100644 --- a/server/mdm/microsoft/wstep.go +++ b/server/mdm/microsoft/wstep.go @@ -45,16 +45,16 @@ type CertManager interface { // NewSTSAuthToken returns an STS auth token for the given UPN claim. NewSTSAuthToken(upn string) (string, error) - // NewSTSAuthTokenWithDeviceID returns an STS auth token for the given UPN and - // Windows MDM device ID claims. Used to pass end-user authentication context - // to the orbit installer so the user is not prompted twice. - NewSTSAuthTokenWithDeviceID(upn string, deviceID string) (string, error) + // NewEUAToken returns a Fleet-signed JWT for the given UPN and Windows MDM + // device ID. Used to pass end-user authentication context to the orbit + // installer so the user is not prompted twice. + NewEUAToken(upn string, deviceID string) (string, error) // GetSTSAuthTokenUPNClaim validates the given token and returns the UPN claim GetSTSAuthTokenUPNClaim(token string) (string, error) - // GetSTSAuthTokenClaims validates the given token and returns the UPN and device ID claims. - GetSTSAuthTokenClaims(token string) (upn string, deviceID string, err error) + // GetEUATokenClaims validates the given EUA token and returns the parsed claims. + GetEUATokenClaims(token string) (*EUATokenClaims, error) // TODO: implement other methods as needed: // - verify certificate-device association @@ -70,11 +70,23 @@ type CertStore interface { } type STSClaims struct { + UPN string `json:"upn"` + jwt.RegisteredClaims +} + +// euaJWTClaims is the internal JWT struct for signing/parsing EUA tokens. +type euaJWTClaims struct { UPN string `json:"upn"` - DeviceID string `json:"device_id,omitempty"` + DeviceID string `json:"device_id"` jwt.RegisteredClaims } +// EUATokenClaims is the validated result returned to callers of GetEUATokenClaims. +type EUATokenClaims struct { + UPN string + DeviceID string +} + type AzureData struct { UPN string Audience []string @@ -214,8 +226,8 @@ func (m *manager) NewSTSAuthToken(upn string) (string, error) { return signedToken, nil } -// NewSTSAuthTokenWithDeviceID returns an STS auth token for the given UPN and Windows MDM device ID claims. -func (m *manager) NewSTSAuthTokenWithDeviceID(upn string, deviceID string) (string, error) { +// NewEUAToken returns a Fleet-signed JWT for the given UPN and Windows MDM device ID. +func (m *manager) NewEUAToken(upn string, deviceID string) (string, error) { if m == nil { return "", errors.New("windows mdm identity keypair was not configured") } @@ -231,58 +243,58 @@ func (m *manager) NewSTSAuthTokenWithDeviceID(upn string, deviceID string) (stri return "", errors.New("invalid device_id field") } - claims := STSClaims{ + claims := euaJWTClaims{ UPN: upn, DeviceID: deviceID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()), - Subject: "STSAuthToken", + Subject: "EUAToken", }, } token := jwt.NewWithClaims(jwt.GetSigningMethod("RS256"), claims) signedToken, err := token.SignedString(m.identityPrivateKey) if err != nil { - return "", fmt.Errorf("failed to sign STS token: %w", err) + return "", fmt.Errorf("failed to sign EUA token: %w", err) } return signedToken, nil } -// GetSTSAuthTokenClaims validates the given token and returns the UPN and device ID claims. -func (m *manager) GetSTSAuthTokenClaims(tokenStr string) (string, string, error) { +// GetEUATokenClaims validates the given EUA token and returns the parsed claims. +func (m *manager) GetEUATokenClaims(tokenStr string) (*EUATokenClaims, error) { if m == nil { - return "", "", errors.New("windows mdm identity keypair was not configured") + return nil, errors.New("windows mdm identity keypair was not configured") } if m.identityCert == nil || m.identityPrivateKey == nil { - return "", "", errors.New("invalid identity certificate or private key") + return nil, errors.New("invalid identity certificate or private key") } if len(tokenStr) == 0 { - return "", "", errors.New("invalid STS token") + return nil, errors.New("invalid EUA token") } - token, err := jwt.ParseWithClaims(tokenStr, &STSClaims{}, func(token *jwt.Token) (any, error) { + token, err := jwt.ParseWithClaims(tokenStr, &euaJWTClaims{}, func(token *jwt.Token) (any, error) { return m.identityCert.PublicKey, nil }) if err != nil { - return "", "", fmt.Errorf("there was an error parsing the STS token claims: %w", err) + return nil, fmt.Errorf("there was an error parsing the EUA token claims: %w", err) } - if claims, ok := token.Claims.(*STSClaims); ok && token.Valid { + if claims, ok := token.Claims.(*euaJWTClaims); ok && token.Valid { if len(claims.UPN) == 0 { - return "", "", errors.New("issue with UPN token claim") + return nil, errors.New("issue with UPN token claim") } if len(claims.DeviceID) == 0 { - return "", "", errors.New("issue with device_id token claim") + return nil, errors.New("issue with device_id token claim") } - return claims.UPN, claims.DeviceID, nil + return &EUATokenClaims{UPN: claims.UPN, DeviceID: claims.DeviceID}, nil } - return "", "", errors.New("issue with STS token validation") + return nil, errors.New("issue with EUA token validation") } // GetSTSAuthToken validates the given token and returns the UPN claim diff --git a/server/mdm/microsoft/wstep_test.go b/server/mdm/microsoft/wstep_test.go index af2daac28b5..bc531603af9 100644 --- a/server/mdm/microsoft/wstep_test.go +++ b/server/mdm/microsoft/wstep_test.go @@ -108,32 +108,32 @@ func TestSTSTokenWithDeviceID(t *testing.T) { deviceID := "test-device-id-123" // Generate token with device ID - token, err := cm.NewSTSAuthTokenWithDeviceID(upn, deviceID) + token, err := cm.NewEUAToken(upn, deviceID) require.NoError(t, err) require.NotEmpty(t, token) // Validate and extract both claims - gotUPN, gotDeviceID, err := cm.GetSTSAuthTokenClaims(token) + claims, err := cm.GetEUATokenClaims(token) require.NoError(t, err) - require.Equal(t, upn, gotUPN) - require.Equal(t, deviceID, gotDeviceID) + require.Equal(t, upn, claims.UPN) + require.Equal(t, deviceID, claims.DeviceID) // Empty UPN is rejected - _, err = cm.NewSTSAuthTokenWithDeviceID("", deviceID) + _, err = cm.NewEUAToken("", deviceID) require.ErrorContains(t, err, "invalid upn field") // Empty device ID is rejected - _, err = cm.NewSTSAuthTokenWithDeviceID(upn, "") + _, err = cm.NewEUAToken(upn, "") require.ErrorContains(t, err, "invalid device_id field") // Token signed by NewSTSAuthToken (no device_id) is rejected — device_id is required oldToken, err := cm.NewSTSAuthToken(upn) require.NoError(t, err) - _, _, err = cm.GetSTSAuthTokenClaims(oldToken) + _, err = cm.GetEUATokenClaims(oldToken) require.ErrorContains(t, err, "issue with device_id token claim") // Tampered token is rejected - _, _, err = cm.GetSTSAuthTokenClaims(token + "tampered") + _, err = cm.GetEUATokenClaims(token + "tampered") require.Error(t, err) } diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 2d707ee1546..ca3de50ae81 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -9173,8 +9173,9 @@ func (s *integrationMDMTestSuite) TestWindowsAutomaticEnrollmentCommands() { var installJob struct { Product struct { - ContentURL string `xml:"Download>ContentURLList>ContentURL"` - FileHash string `xml:"Validation>FileHash"` + ContentURL string `xml:"Download>ContentURLList>ContentURL"` + FileHash string `xml:"Validation>FileHash"` + CommandLine string `xml:"Enforcement>CommandLine"` } `xml:"Product"` } err = xml.Unmarshal([]byte(fleetdExecCmd.Cmd.Items[0].Data.Content), &installJob) @@ -9182,6 +9183,12 @@ func (s *integrationMDMTestSuite) TestWindowsAutomaticEnrollmentCommands() { require.Equal(t, s.mockedDownloadFleetdmMeta.MSIURL, installJob.Product.ContentURL) require.Equal(t, s.mockedDownloadFleetdmMeta.MSISha256, installJob.Product.FileHash) + // The device enrolled with a valid UPN (azureMail), so the command line + // should include an EUA_TOKEN argument. + require.Contains(t, installJob.Product.CommandLine, `EUA_TOKEN="`) + require.Contains(t, installJob.Product.CommandLine, `FLEET_URL="`) + require.Contains(t, installJob.Product.CommandLine, `FLEET_SECRET="`) + // reply with success for both commands msgID, err := d.GetCurrentMsgID() require.NoError(t, err) diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go index 53b450a9f46..c2915579944 100644 --- a/server/service/microsoft_mdm.go +++ b/server/service/microsoft_mdm.go @@ -1509,15 +1509,15 @@ func (svc *Service) generateWindowsEUAToken(ctx context.Context, deviceID string } device, err := svc.ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID) if err != nil { - svc.logger.WarnContext(ctx, "unable to fetch windows mdm enrollment for EUA token generation", "err", err, "device_id", deviceID) + svc.logger.ErrorContext(ctx, "unable to fetch windows mdm enrollment for EUA token generation", "err", err, "device_id", deviceID) return "" } if device == nil || !microsoft_mdm.IsValidUPN(device.MDMEnrollUserID) { return "" } - token, err := svc.wstepCertManager.NewSTSAuthTokenWithDeviceID(device.MDMEnrollUserID, deviceID) + token, err := svc.wstepCertManager.NewEUAToken(device.MDMEnrollUserID, deviceID) if err != nil { - svc.logger.WarnContext(ctx, "unable to generate EUA token for fleetd install", "err", err, "device_id", deviceID) + svc.logger.ErrorContext(ctx, "unable to generate EUA token for fleetd install", "err", err, "device_id", deviceID) return "" } return token diff --git a/server/service/orbit.go b/server/service/orbit.go index dc31fa1eaf9..c395673f16a 100644 --- a/server/service/orbit.go +++ b/server/service/orbit.go @@ -99,12 +99,14 @@ func (svc *Service) processWindowsEUAToken(ctx context.Context, hostUUID string, return "", "", fleet.NewOrbitIDPAuthRequiredError() } - upn, deviceID, tokenErr := svc.wstepCertManager.GetSTSAuthTokenClaims(euaToken) + claims, tokenErr := svc.wstepCertManager.GetEUATokenClaims(euaToken) if tokenErr != nil { svc.logger.WarnContext(ctx, "EUA token validation failed, falling back to end user auth prompt", "err", tokenErr, "host_uuid", hostUUID) return "", "", fleet.NewOrbitIDPAuthRequiredError() } + upn = claims.UPN + deviceID = claims.DeviceID _, err = svc.ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID) if err != nil { @@ -128,7 +130,7 @@ func (svc *Service) processWindowsEUAToken(ctx context.Context, hostUUID string, return "", "", ctxerr.Wrap(ctx, err, "inserting mdm idp account for EUA token") } // Re-fetch to get the UUID assigned by the DB. - acct, err = svc.ds.GetMDMIdPAccountByEmail(ctx, upn) + acct, err = svc.ds.GetMDMIdPAccountByEmail(ctxdb.RequirePrimary(ctx, true), upn) if err != nil { return "", "", ctxerr.Wrap(ctx, err, "re-fetching mdm idp account after insert for EUA token") } @@ -241,12 +243,12 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf if platform == "linux" || platform == "windows" { // If the Orbit client doesn't support end user auth, complain loudly and let the host enroll. mp, ok := capabilities.FromContext(ctx) - //nolint:gocritic // ignore ifElseChain - if !ok { - svc.logger.ErrorContext(ctx, "!!! ERR_ALLOWING_UNAUTHENTICATED: host is not authenticated, but fleet could not determine whether orbit supports end-user authentication. proceeding with enrollment. !!! ", "host_uuid", hostInfo.HardwareUUID) - } else if !mp.Has(fleet.CapabilityEndUserAuth) { - svc.logger.WarnContext(ctx, "!!! ERR_ALLOWING_UNAUTHENTICATED: host is not authenticated, but connected with an orbit version that does not support end user authentication. proceeding with enrollment. !!! ", "host_uuid", hostInfo.HardwareUUID) - } else if platform == "windows" && euaToken != "" { + switch { + case !ok: + svc.logger.ErrorContext(ctx, "allowing unauthenticated enrollment: could not determine orbit end-user auth capability", "host_uuid", hostInfo.HardwareUUID) + case !mp.Has(fleet.CapabilityEndUserAuth): + svc.logger.WarnContext(ctx, "allowing unauthenticated enrollment: orbit version does not support end-user authentication", "host_uuid", hostInfo.HardwareUUID) + case platform == "windows" && euaToken != "": // A Windows host already authenticated during MDM enrollment and the // EUA token was passed by the MSI installer. upn, deviceID, err := svc.processWindowsEUAToken(ctx, hostInfo.HardwareUUID, euaToken) @@ -256,7 +258,7 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf euaUPN = upn euaDeviceID = deviceID // Continue enrollment — do not return END_USER_AUTH_REQUIRED. - } else { + default: // Otherwise report the unauthenticated host and let Orbit handle it (e.g. by prompting the user to authenticate). return "", fleet.NewOrbitIDPAuthRequiredError() } diff --git a/server/service/orbit_eua_test.go b/server/service/orbit_eua_test.go index e4fa1d954cf..33eb031c940 100644 --- a/server/service/orbit_eua_test.go +++ b/server/service/orbit_eua_test.go @@ -100,7 +100,7 @@ func TestProcessWindowsEUAToken(t *testing.T) { // Helper to generate a valid token for test cases. makeToken := func(t *testing.T, svc *Service, upn, deviceID string) string { t.Helper() - tok, err := svc.wstepCertManager.NewSTSAuthTokenWithDeviceID(upn, deviceID) + tok, err := svc.wstepCertManager.NewEUAToken(upn, deviceID) require.NoError(t, err) return tok } @@ -191,7 +191,7 @@ func TestProcessWindowsEUAToken(t *testing.T) { require.True(t, ds.AssociateHostMDMIdPAccountDBFuncInvoked, "should still link idp account even when enrollment has host_uuid") }) - t.Run("expired or invalid token falls back to END_USER_AUTH_REQUIRED", func(t *testing.T) { + t.Run("invalid token falls back to END_USER_AUTH_REQUIRED", func(t *testing.T) { ds := new(mock.Store) svc := newTestServiceWithWSTEP(t, ds) @@ -250,10 +250,10 @@ func TestGenerateWindowsEUAToken(t *testing.T) { require.NotEmpty(t, token) // Token should be valid and contain expected claims. - upn, deviceID, err := svc.wstepCertManager.GetSTSAuthTokenClaims(token) + claims, err := svc.wstepCertManager.GetEUATokenClaims(token) require.NoError(t, err) - require.Equal(t, testUPN, upn) - require.Equal(t, testDeviceID, deviceID) + require.Equal(t, testUPN, claims.UPN) + require.Equal(t, testDeviceID, claims.DeviceID) }) t.Run("returns empty string when device has no UPN", func(t *testing.T) { @@ -272,7 +272,7 @@ func TestGenerateWindowsEUAToken(t *testing.T) { svc := newTestServiceWithWSTEP(t, ds) ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, mdmDeviceID string) (*fleet.MDMWindowsEnrolledDevice, error) { - return nil, nil + return nil, mysql_errors.NotFound("MDMWindowsEnrolledDevice") } require.Empty(t, svc.generateWindowsEUAToken(context.Background(), testDeviceID)) From b7c395015528aafa5a6a4867a6127bded7c547ad Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Fri, 10 Apr 2026 14:39:17 -0500 Subject: [PATCH 08/15] vulnerability --- server/mdm/microsoft/wstep.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/mdm/microsoft/wstep.go b/server/mdm/microsoft/wstep.go index 663ebdfb0ed..9bc329e0508 100644 --- a/server/mdm/microsoft/wstep.go +++ b/server/mdm/microsoft/wstep.go @@ -278,6 +278,9 @@ func (m *manager) GetEUATokenClaims(tokenStr string) (*EUATokenClaims, error) { } token, err := jwt.ParseWithClaims(tokenStr, &euaJWTClaims{}, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } return m.identityCert.PublicKey, nil }) if err != nil { From 264fe5b090324678e9be6f993fc868eb5fc6c496 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Mon, 13 Apr 2026 11:40:20 -0500 Subject: [PATCH 09/15] fixing test --- server/service/integration_mdm_lifecycle_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index 62636fa8567..eefefc2f3cb 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -13,6 +13,7 @@ import ( "net/http" "os" "path/filepath" + "regexp" "strings" "testing" "time" @@ -508,6 +509,7 @@ func (s *integrationMDMTestSuite) recordWindowsHostStatus( msgID, err := device.GetCurrentMsgID() require.NoError(t, err) + euaTokenRe := regexp.MustCompile(`EUA_TOKEN="[^"]*"`) for _, c := range cmds { cmdID := c.Cmd.CmdID status := syncml.CmdStatusOK @@ -522,6 +524,12 @@ func (s *integrationMDMTestSuite) recordWindowsHostStatus( }) c.Cmd.CmdID.Value = "" c.Cmd.CmdRef = nil + for i := range c.Cmd.Items { + if c.Cmd.Items[i].Data != nil { + c.Cmd.Items[i].Data.Content = euaTokenRe.ReplaceAllString( + c.Cmd.Items[i].Data.Content, `EUA_TOKEN=""`) + } + } recordedCmds = append(recordedCmds, c) } From 8c36dd2dff90e4c6230c0e1a2a3d739559e2f745 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Thu, 9 Apr 2026 15:16:36 -0500 Subject: [PATCH 10/15] Orbit passes EUA token during enrollment --- changes/41379-orbit-eua | 1 + client/orbit_client.go | 10 +++ client/orbit_client_eua_test.go | 95 ++++++++++++++++++++++++ orbit/cmd/orbit/orbit.go | 12 +++ orbit/pkg/packaging/packaging.go | 2 + orbit/pkg/packaging/windows.go | 4 + orbit/pkg/packaging/windows_eua_test.go | 67 +++++++++++++++++ orbit/pkg/packaging/windows_templates.go | 7 +- 8 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 changes/41379-orbit-eua create mode 100644 client/orbit_client_eua_test.go create mode 100644 orbit/pkg/packaging/windows_eua_test.go diff --git a/changes/41379-orbit-eua b/changes/41379-orbit-eua new file mode 100644 index 00000000000..e9a0f11250c --- /dev/null +++ b/changes/41379-orbit-eua @@ -0,0 +1 @@ +* Orbit passes EUA token during enrollment request \ No newline at end of file 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..d69aaf42246 --- /dev/null +++ b/client/orbit_client_eua_test.go @@ -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/require" +) + +func TestEnrollSendsEUAToken(t *testing.T) { + const ( + testToken = "eyJhbGciOiJSUzI1NiJ9.test-eua-token" + 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) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, &receivedBody)) + + resp := fleet.EnrollOrbitResponse{OrbitNodeKey: testNodeKey} + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.NoError(t, err) + })) + defer srv.Close() + + oc := &OrbitClient{ + enrollSecret: "secret", + hostInfo: fleet.OrbitHostInfo{HardwareUUID: "uuid-1", Platform: "windows"}, + euaToken: testToken, + } + 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, testToken, 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) + require.NoError(t, err) + + resp := fleet.EnrollOrbitResponse{OrbitNodeKey: testNodeKey} + w.Header().Set("Content-Type", "application/json") + err = json.NewEncoder(w).Encode(resp) + require.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.False(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) +} 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..6dd87e2e2a8 --- /dev/null +++ b/orbit/pkg/packaging/windows_eua_test.go @@ -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, ``) + 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.Split(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 + } + } + t.Fatal("ServiceInstall Arguments line not found in template output") + }) +} 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 }}' > Date: Fri, 10 Apr 2026 10:27:33 -0500 Subject: [PATCH 11/15] format correctly --- orbit/pkg/packaging/windows_eua_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/orbit/pkg/packaging/windows_eua_test.go b/orbit/pkg/packaging/windows_eua_test.go index 6dd87e2e2a8..a82c625681a 100644 --- a/orbit/pkg/packaging/windows_eua_test.go +++ b/orbit/pkg/packaging/windows_eua_test.go @@ -11,13 +11,13 @@ import ( func TestWindowsWixTemplateEUAToken(t *testing.T) { baseOpt := Options{ - FleetURL: "https://fleet.example.com", - EnrollSecret: "secret", - OrbitChannel: "stable", + FleetURL: "https://fleet.example.com", + EnrollSecret: "secret", + OrbitChannel: "stable", OsquerydChannel: "stable", - DesktopChannel: "stable", - NativePlatform: "windows", - Architecture: ArchAmd64, + DesktopChannel: "stable", + NativePlatform: "windows", + Architecture: ArchAmd64, } t.Run("EUA_TOKEN property and flag included when enabled", func(t *testing.T) { From 1bf85aad2e4032a2368d272bb63d385f27036453 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Fri, 10 Apr 2026 11:07:34 -0500 Subject: [PATCH 12/15] linting errors --- client/orbit_client_eua_test.go | 22 +++++++++++----------- orbit/pkg/packaging/windows_eua_test.go | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/client/orbit_client_eua_test.go b/client/orbit_client_eua_test.go index d69aaf42246..e1bfa1aa8a9 100644 --- a/client/orbit_client_eua_test.go +++ b/client/orbit_client_eua_test.go @@ -9,34 +9,34 @@ import ( "testing" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestEnrollSendsEUAToken(t *testing.T) { - const ( - testToken = "eyJhbGciOiJSUzI1NiJ9.test-eua-token" - testNodeKey = "test-node-key-abc" - ) + // 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) - require.NoError(t, err) - require.NoError(t, json.Unmarshal(body, &receivedBody)) + 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) - require.NoError(t, err) + assert.NoError(t, err) })) defer srv.Close() oc := &OrbitClient{ enrollSecret: "secret", hostInfo: fleet.OrbitHostInfo{HardwareUUID: "uuid-1", Platform: "windows"}, - euaToken: testToken, + euaToken: euaTokenValue, } bc, err := NewBaseClient(srv.URL, true, "", "", nil, fleet.CapabilityMap{}, nil) require.NoError(t, err) @@ -45,7 +45,7 @@ func TestEnrollSendsEUAToken(t *testing.T) { nodeKey, err := oc.enroll() require.NoError(t, err) require.Equal(t, testNodeKey, nodeKey) - require.Equal(t, testToken, receivedBody.EUAToken) + require.Equal(t, euaTokenValue, receivedBody.EUAToken) require.Equal(t, "secret", receivedBody.EnrollSecret) require.Equal(t, "uuid-1", receivedBody.HardwareUUID) }) @@ -56,12 +56,12 @@ func TestEnrollSendsEUAToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var err error rawBody, err = io.ReadAll(r.Body) - require.NoError(t, err) + assert.NoError(t, err) resp := fleet.EnrollOrbitResponse{OrbitNodeKey: testNodeKey} w.Header().Set("Content-Type", "application/json") err = json.NewEncoder(w).Encode(resp) - require.NoError(t, err) + assert.NoError(t, err) })) defer srv.Close() diff --git a/orbit/pkg/packaging/windows_eua_test.go b/orbit/pkg/packaging/windows_eua_test.go index a82c625681a..2496f4f72aa 100644 --- a/orbit/pkg/packaging/windows_eua_test.go +++ b/orbit/pkg/packaging/windows_eua_test.go @@ -55,7 +55,7 @@ func TestWindowsWixTemplateEUAToken(t *testing.T) { require.NoError(t, err) // Find the ServiceInstall Arguments line and verify eua-token is in it. - for _, line := range strings.Split(buf.String(), "\n") { + 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") From feecb24102d8a40e19298633905fbec43cea1e90 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Fri, 10 Apr 2026 13:41:50 -0500 Subject: [PATCH 13/15] Update client/orbit_client_eua_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- client/orbit_client_eua_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/orbit_client_eua_test.go b/client/orbit_client_eua_test.go index e1bfa1aa8a9..3ed7f764f75 100644 --- a/client/orbit_client_eua_test.go +++ b/client/orbit_client_eua_test.go @@ -78,7 +78,7 @@ func TestEnrollSendsEUAToken(t *testing.T) { require.NoError(t, err) // Verify the eua_token key is not present in the JSON body. - require.False(t, bytes.Contains(rawBody, []byte(`"eua_token"`)), + require.Falsef(t, bytes.Contains(rawBody, []byte(`"eua_token"`)), "eua_token should not appear in JSON when empty, got: %s", string(rawBody)) }) } From a52da4adc927f4d07e82646aca73e01bf14fcc32 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Mon, 13 Apr 2026 12:08:48 -0500 Subject: [PATCH 14/15] feedback --- client/orbit_client_eua_test.go | 132 +++++++++------------ {changes => orbit/changes}/41379-orbit-eua | 0 orbit/pkg/packaging/windows_eua_test.go | 31 ++--- 3 files changed, 70 insertions(+), 93 deletions(-) rename {changes => orbit/changes}/41379-orbit-eua (100%) diff --git a/client/orbit_client_eua_test.go b/client/orbit_client_eua_test.go index 3ed7f764f75..e906702755d 100644 --- a/client/orbit_client_eua_test.go +++ b/client/orbit_client_eua_test.go @@ -18,78 +18,64 @@ func TestEnrollSendsEUAToken(t *testing.T) { 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() - - 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)) - }) + 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) + }) + } } -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) -} diff --git a/changes/41379-orbit-eua b/orbit/changes/41379-orbit-eua similarity index 100% rename from changes/41379-orbit-eua rename to orbit/changes/41379-orbit-eua diff --git a/orbit/pkg/packaging/windows_eua_test.go b/orbit/pkg/packaging/windows_eua_test.go index 2496f4f72aa..e81528dcf36 100644 --- a/orbit/pkg/packaging/windows_eua_test.go +++ b/orbit/pkg/packaging/windows_eua_test.go @@ -30,7 +30,17 @@ func TestWindowsWixTemplateEUAToken(t *testing.T) { output := buf.String() assert.Contains(t, output, ``) - assert.Contains(t, output, `--eua-token="[EUA_TOKEN]"`) + + 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) { @@ -45,23 +55,4 @@ func TestWindowsWixTemplateEUAToken(t *testing.T) { 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 - } - } - t.Fatal("ServiceInstall Arguments line not found in template output") - }) } From a2da5d4b6b6c6965dae56de3fd27e5ddd1a3accd Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Mon, 13 Apr 2026 12:24:09 -0500 Subject: [PATCH 15/15] linting error --- client/orbit_client_eua_test.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/client/orbit_client_eua_test.go b/client/orbit_client_eua_test.go index e906702755d..b3e8343ae2a 100644 --- a/client/orbit_client_eua_test.go +++ b/client/orbit_client_eua_test.go @@ -19,9 +19,9 @@ func TestEnrollSendsEUAToken(t *testing.T) { const testNodeKey = "test-node-key-abc" testCases := []struct { - name string - token string - assert func(t *testing.T, receivedBody fleet.EnrollOrbitRequest, rawBody []byte) + name string + token string + assert func(t *testing.T, receivedBody fleet.EnrollOrbitRequest, rawBody []byte) }{ { name: "eua_token included in enroll request when set", @@ -78,4 +78,3 @@ func TestEnrollSendsEUAToken(t *testing.T) { }) } } -