From 83a886b0eca29aba12757a8a8ac3d5ae688f6eb5 Mon Sep 17 00:00:00 2001 From: Konstantin Sykulev Date: Mon, 13 Apr 2026 13:17:23 -0400 Subject: [PATCH] Added EUA to the Fleet MSI installer (#43295) **Related issue:** Resolves #41381 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [ ] QA'd all new/changed functionality manually ## Summary by CodeRabbit * **New Features** - Forward end-user authentication context (EUA token) to the Fleet MSI installer and enrollment flow on Windows MDM to avoid duplicate auth prompts and link devices to hosts. * **Tests** - Added comprehensive unit and integration tests for EUA token creation, validation, and processing to improve reliability. * **Documentation** - Added a note describing support for forwarding end-user authentication context during Windows MDM enrollment. --- changes/41381-eua-ms-installer | 1 + server/fleet/api_orbit.go | 2 + server/fleet/service.go | 2 +- server/mdm/microsoft/wstep.go | 99 +++++- server/mdm/microsoft/wstep_test.go | 38 +++ server/mock/service/service_mock.go | 6 +- .../service/integration_mdm_lifecycle_test.go | 8 + server/service/integration_mdm_test.go | 11 +- server/service/microsoft_mdm.go | 29 +- server/service/orbit.go | 113 ++++++- server/service/orbit_eua_test.go | 303 ++++++++++++++++++ 11 files changed, 595 insertions(+), 17 deletions(-) create mode 100644 changes/41381-eua-ms-installer create mode 100644 server/service/orbit_eua_test.go diff --git a/changes/41381-eua-ms-installer b/changes/41381-eua-ms-installer new file mode 100644 index 0000000000..4408776558 --- /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 7a4610f274..8acddec765 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 c653489aff..da3c3df32b 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 ee2305cce5..9bc329e050 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) + // 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) + // 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 // - certificate lifecycle management (e.g., renewal, revocation) @@ -66,6 +74,19 @@ type STSClaims struct { 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"` + 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 @@ -186,8 +207,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 +226,80 @@ func (m *manager) NewSTSAuthToken(upn string) (string, error) { return signedToken, nil } +// 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") + } + + 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") + } + if len(deviceID) == 0 { + return "", errors.New("invalid device_id field") + } + + 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: "EUAToken", + }, + } + + token := jwt.NewWithClaims(jwt.GetSigningMethod("RS256"), claims) + signedToken, err := token.SignedString(m.identityPrivateKey) + if err != nil { + return "", fmt.Errorf("failed to sign EUA token: %w", err) + } + + return signedToken, nil +} + +// GetEUATokenClaims validates the given EUA token and returns the parsed claims. +func (m *manager) GetEUATokenClaims(tokenStr string) (*EUATokenClaims, error) { + if m == nil { + return nil, errors.New("windows mdm identity keypair was not configured") + } + + if m.identityCert == nil || m.identityPrivateKey == nil { + return nil, errors.New("invalid identity certificate or private key") + } + + if len(tokenStr) == 0 { + return nil, errors.New("invalid EUA token") + } + + 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 { + return nil, fmt.Errorf("there was an error parsing the EUA token claims: %w", err) + } + + if claims, ok := token.Claims.(*euaJWTClaims); ok && token.Valid { + if len(claims.UPN) == 0 { + return nil, errors.New("issue with UPN token claim") + } + if len(claims.DeviceID) == 0 { + return nil, errors.New("issue with device_id token claim") + } + return &EUATokenClaims{UPN: claims.UPN, DeviceID: claims.DeviceID}, nil + } + + return nil, errors.New("issue with EUA 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 d6e4da3e34..bc531603af 100644 --- a/server/mdm/microsoft/wstep_test.go +++ b/server/mdm/microsoft/wstep_test.go @@ -99,6 +99,44 @@ 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.NewEUAToken(upn, deviceID) + require.NoError(t, err) + require.NotEmpty(t, token) + + // Validate and extract both claims + claims, err := cm.GetEUATokenClaims(token) + require.NoError(t, err) + require.Equal(t, upn, claims.UPN) + require.Equal(t, deviceID, claims.DeviceID) + + // Empty UPN is rejected + _, err = cm.NewEUAToken("", deviceID) + require.ErrorContains(t, err, "invalid upn field") + + // Empty device ID is rejected + _, 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.GetEUATokenClaims(oldToken) + require.ErrorContains(t, err, "issue with device_id token claim") + + // Tampered token is rejected + _, err = cm.GetEUATokenClaims(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 47065893b4..685cc9cec0 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/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index 62636fa856..eefefc2f3c 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) } diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 2d707ee154..ca3de50ae8 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 c506c805fc..c291557994 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.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.NewEUAToken(device.MDMEnrollUserID, deviceID) + if err != nil { + svc.logger.ErrorContext(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 103ee328be..c395673f16 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,66 @@ 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) { + 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() + } + + 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 { + 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") + } + + // 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") + } + // Re-fetch to get the UUID assigned by the DB. + 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") + } + } + 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") + } + + 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 +220,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"} @@ -183,12 +243,22 @@ 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 { + 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) + if err != nil { + return "", err + } + euaUPN = upn + euaDeviceID = deviceID + // Continue enrollment — do not return END_USER_AUTH_REQUIRED. + default: // Otherwise report the unauthenticated host and let Orbit handle it (e.g. by prompting the user to authenticate). return "", fleet.NewOrbitIDPAuthRequiredError() } @@ -221,6 +291,33 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf return "", fleet.OrbitError{Message: "failed to enroll " + err.Error()} } + if euaDeviceID != "" { + 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) + } + + 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) + } + } + } + } + // 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. diff --git a/server/service/orbit_eua_test.go b/server/service/orbit_eua_test.go new file mode 100644 index 0000000000..33eb031c94 --- /dev/null +++ b/server/service/orbit_eua_test.go @@ -0,0 +1,303 @@ +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.NewEUAToken(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("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. + claims, err := svc.wstepCertManager.GetEUATokenClaims(token) + require.NoError(t, err) + 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) { + 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, mysql_errors.NotFound("MDMWindowsEnrolledDevice") + } + + 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") + }) +}