Skip to content

Commit 83a886b

Browse files
authored
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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent b4a3e97 commit 83a886b

11 files changed

Lines changed: 595 additions & 17 deletions

File tree

changes/41381-eua-ms-installer

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

server/fleet/api_orbit.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ type EnrollOrbitRequest struct {
3232
ComputerName string `json:"computer_name"`
3333
// HardwareModel is the device's hardware model.
3434
HardwareModel string `json:"hardware_model"`
35+
// EUAToken is a Fleet-signed JWT containing the user's UPN and Windows MDM device ID.
36+
EUAToken string `json:"eua_token,omitempty"`
3537
}
3638

3739
// SetOrbitNodeKeyer is the interface implemented by orbit request types that

server/fleet/service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ type Service interface {
122122
//
123123
// - If an entry for the host exists (osquery enrolled first) then it will update the host's orbit node key and team.
124124
// - If an entry for the host doesn't exist (osquery enrolls later) then it will create a new entry in the hosts table.
125-
EnrollOrbit(ctx context.Context, hostInfo OrbitHostInfo, enrollSecret string) (orbitNodeKey string, err error)
125+
EnrollOrbit(ctx context.Context, hostInfo OrbitHostInfo, enrollSecret string, euaToken string) (orbitNodeKey string, err error)
126126
// GetOrbitConfig returns team specific flags and extensions in agent options
127127
// if the team id is not nil for host, otherwise it returns flags from global
128128
// agent options. It also returns any notifications that fleet wants to surface

server/mdm/microsoft/wstep.go

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,17 @@ type CertManager interface {
4545
// NewSTSAuthToken returns an STS auth token for the given UPN claim.
4646
NewSTSAuthToken(upn string) (string, error)
4747

48+
// NewEUAToken returns a Fleet-signed JWT for the given UPN and Windows MDM
49+
// device ID. Used to pass end-user authentication context to the orbit
50+
// installer so the user is not prompted twice.
51+
NewEUAToken(upn string, deviceID string) (string, error)
52+
4853
// GetSTSAuthTokenUPNClaim validates the given token and returns the UPN claim
4954
GetSTSAuthTokenUPNClaim(token string) (string, error)
5055

56+
// GetEUATokenClaims validates the given EUA token and returns the parsed claims.
57+
GetEUATokenClaims(token string) (*EUATokenClaims, error)
58+
5159
// TODO: implement other methods as needed:
5260
// - verify certificate-device association
5361
// - certificate lifecycle management (e.g., renewal, revocation)
@@ -66,6 +74,19 @@ type STSClaims struct {
6674
jwt.RegisteredClaims
6775
}
6876

77+
// euaJWTClaims is the internal JWT struct for signing/parsing EUA tokens.
78+
type euaJWTClaims struct {
79+
UPN string `json:"upn"`
80+
DeviceID string `json:"device_id"`
81+
jwt.RegisteredClaims
82+
}
83+
84+
// EUATokenClaims is the validated result returned to callers of GetEUATokenClaims.
85+
type EUATokenClaims struct {
86+
UPN string
87+
DeviceID string
88+
}
89+
6990
type AzureData struct {
7091
UPN string
7192
Audience []string
@@ -186,8 +207,8 @@ func (m *manager) NewSTSAuthToken(upn string) (string, error) {
186207

187208
// Create claims with upn field populated
188209
claims := STSClaims{
189-
upn,
190-
jwt.RegisteredClaims{
210+
UPN: upn,
211+
RegisteredClaims: jwt.RegisteredClaims{
191212
ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)),
192213
IssuedAt: jwt.NewNumericDate(time.Now()),
193214
NotBefore: jwt.NewNumericDate(time.Now()),
@@ -205,6 +226,80 @@ func (m *manager) NewSTSAuthToken(upn string) (string, error) {
205226
return signedToken, nil
206227
}
207228

229+
// NewEUAToken returns a Fleet-signed JWT for the given UPN and Windows MDM device ID.
230+
func (m *manager) NewEUAToken(upn string, deviceID string) (string, error) {
231+
if m == nil {
232+
return "", errors.New("windows mdm identity keypair was not configured")
233+
}
234+
235+
if m.identityCert == nil || m.identityPrivateKey == nil {
236+
return "", errors.New("invalid identity certificate or private key")
237+
}
238+
239+
if len(upn) == 0 {
240+
return "", errors.New("invalid upn field")
241+
}
242+
if len(deviceID) == 0 {
243+
return "", errors.New("invalid device_id field")
244+
}
245+
246+
claims := euaJWTClaims{
247+
UPN: upn,
248+
DeviceID: deviceID,
249+
RegisteredClaims: jwt.RegisteredClaims{
250+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
251+
IssuedAt: jwt.NewNumericDate(time.Now()),
252+
NotBefore: jwt.NewNumericDate(time.Now()),
253+
Subject: "EUAToken",
254+
},
255+
}
256+
257+
token := jwt.NewWithClaims(jwt.GetSigningMethod("RS256"), claims)
258+
signedToken, err := token.SignedString(m.identityPrivateKey)
259+
if err != nil {
260+
return "", fmt.Errorf("failed to sign EUA token: %w", err)
261+
}
262+
263+
return signedToken, nil
264+
}
265+
266+
// GetEUATokenClaims validates the given EUA token and returns the parsed claims.
267+
func (m *manager) GetEUATokenClaims(tokenStr string) (*EUATokenClaims, error) {
268+
if m == nil {
269+
return nil, errors.New("windows mdm identity keypair was not configured")
270+
}
271+
272+
if m.identityCert == nil || m.identityPrivateKey == nil {
273+
return nil, errors.New("invalid identity certificate or private key")
274+
}
275+
276+
if len(tokenStr) == 0 {
277+
return nil, errors.New("invalid EUA token")
278+
}
279+
280+
token, err := jwt.ParseWithClaims(tokenStr, &euaJWTClaims{}, func(token *jwt.Token) (any, error) {
281+
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
282+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
283+
}
284+
return m.identityCert.PublicKey, nil
285+
})
286+
if err != nil {
287+
return nil, fmt.Errorf("there was an error parsing the EUA token claims: %w", err)
288+
}
289+
290+
if claims, ok := token.Claims.(*euaJWTClaims); ok && token.Valid {
291+
if len(claims.UPN) == 0 {
292+
return nil, errors.New("issue with UPN token claim")
293+
}
294+
if len(claims.DeviceID) == 0 {
295+
return nil, errors.New("issue with device_id token claim")
296+
}
297+
return &EUATokenClaims{UPN: claims.UPN, DeviceID: claims.DeviceID}, nil
298+
}
299+
300+
return nil, errors.New("issue with EUA token validation")
301+
}
302+
208303
// GetSTSAuthToken validates the given token and returns the UPN claim
209304
func (m *manager) GetSTSAuthTokenUPNClaim(tokenStr string) (string, error) {
210305
if m == nil {

server/mdm/microsoft/wstep_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,44 @@ func TestSTSTokenSigningAndVerification(t *testing.T) {
9999
require.ErrorContains(t, err, "invalid upn field")
100100
}
101101

102+
func TestSTSTokenWithDeviceID(t *testing.T) {
103+
var store CertStore
104+
cm, err := NewCertManager(store, testCert, testKey)
105+
require.NoError(t, err)
106+
107+
upn := "user@example.com"
108+
deviceID := "test-device-id-123"
109+
110+
// Generate token with device ID
111+
token, err := cm.NewEUAToken(upn, deviceID)
112+
require.NoError(t, err)
113+
require.NotEmpty(t, token)
114+
115+
// Validate and extract both claims
116+
claims, err := cm.GetEUATokenClaims(token)
117+
require.NoError(t, err)
118+
require.Equal(t, upn, claims.UPN)
119+
require.Equal(t, deviceID, claims.DeviceID)
120+
121+
// Empty UPN is rejected
122+
_, err = cm.NewEUAToken("", deviceID)
123+
require.ErrorContains(t, err, "invalid upn field")
124+
125+
// Empty device ID is rejected
126+
_, err = cm.NewEUAToken(upn, "")
127+
require.ErrorContains(t, err, "invalid device_id field")
128+
129+
// Token signed by NewSTSAuthToken (no device_id) is rejected — device_id is required
130+
oldToken, err := cm.NewSTSAuthToken(upn)
131+
require.NoError(t, err)
132+
_, err = cm.GetEUATokenClaims(oldToken)
133+
require.ErrorContains(t, err, "issue with device_id token claim")
134+
135+
// Tampered token is rejected
136+
_, err = cm.GetEUATokenClaims(token + "tampered")
137+
require.Error(t, err)
138+
}
139+
102140
func TestCertFingerprintHexStr(t *testing.T) {
103141
cases := []struct {
104142
name string

server/mock/service/service_mock.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ type GetTransparencyURLFunc func(ctx context.Context) (string, error)
5050

5151
type AuthenticateOrbitHostFunc func(ctx context.Context, nodeKey string) (host *fleet.Host, debug bool, err error)
5252

53-
type EnrollOrbitFunc func(ctx context.Context, hostInfo fleet.OrbitHostInfo, enrollSecret string) (orbitNodeKey string, err error)
53+
type EnrollOrbitFunc func(ctx context.Context, hostInfo fleet.OrbitHostInfo, enrollSecret string, euaToken string) (orbitNodeKey string, err error)
5454

5555
type GetOrbitConfigFunc func(ctx context.Context) (fleet.OrbitConfig, error)
5656

@@ -2354,11 +2354,11 @@ func (s *Service) AuthenticateOrbitHost(ctx context.Context, nodeKey string) (ho
23542354
return s.AuthenticateOrbitHostFunc(ctx, nodeKey)
23552355
}
23562356

2357-
func (s *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInfo, enrollSecret string) (orbitNodeKey string, err error) {
2357+
func (s *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInfo, enrollSecret string, euaToken string) (orbitNodeKey string, err error) {
23582358
s.mu.Lock()
23592359
s.EnrollOrbitFuncInvoked = true
23602360
s.mu.Unlock()
2361-
return s.EnrollOrbitFunc(ctx, hostInfo, enrollSecret)
2361+
return s.EnrollOrbitFunc(ctx, hostInfo, enrollSecret, euaToken)
23622362
}
23632363

23642364
func (s *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, error) {

server/service/integration_mdm_lifecycle_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"net/http"
1414
"os"
1515
"path/filepath"
16+
"regexp"
1617
"strings"
1718
"testing"
1819
"time"
@@ -508,6 +509,7 @@ func (s *integrationMDMTestSuite) recordWindowsHostStatus(
508509

509510
msgID, err := device.GetCurrentMsgID()
510511
require.NoError(t, err)
512+
euaTokenRe := regexp.MustCompile(`EUA_TOKEN="[^"]*"`)
511513
for _, c := range cmds {
512514
cmdID := c.Cmd.CmdID
513515
status := syncml.CmdStatusOK
@@ -522,6 +524,12 @@ func (s *integrationMDMTestSuite) recordWindowsHostStatus(
522524
})
523525
c.Cmd.CmdID.Value = ""
524526
c.Cmd.CmdRef = nil
527+
for i := range c.Cmd.Items {
528+
if c.Cmd.Items[i].Data != nil {
529+
c.Cmd.Items[i].Data.Content = euaTokenRe.ReplaceAllString(
530+
c.Cmd.Items[i].Data.Content, `EUA_TOKEN="<redacted>"`)
531+
}
532+
}
525533
recordedCmds = append(recordedCmds, c)
526534
}
527535

server/service/integration_mdm_test.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9173,15 +9173,22 @@ func (s *integrationMDMTestSuite) TestWindowsAutomaticEnrollmentCommands() {
91739173

91749174
var installJob struct {
91759175
Product struct {
9176-
ContentURL string `xml:"Download>ContentURLList>ContentURL"`
9177-
FileHash string `xml:"Validation>FileHash"`
9176+
ContentURL string `xml:"Download>ContentURLList>ContentURL"`
9177+
FileHash string `xml:"Validation>FileHash"`
9178+
CommandLine string `xml:"Enforcement>CommandLine"`
91789179
} `xml:"Product"`
91799180
}
91809181
err = xml.Unmarshal([]byte(fleetdExecCmd.Cmd.Items[0].Data.Content), &installJob)
91819182
require.NoError(t, err)
91829183
require.Equal(t, s.mockedDownloadFleetdmMeta.MSIURL, installJob.Product.ContentURL)
91839184
require.Equal(t, s.mockedDownloadFleetdmMeta.MSISha256, installJob.Product.FileHash)
91849185

9186+
// The device enrolled with a valid UPN (azureMail), so the command line
9187+
// should include an EUA_TOKEN argument.
9188+
require.Contains(t, installJob.Product.CommandLine, `EUA_TOKEN="`)
9189+
require.Contains(t, installJob.Product.CommandLine, `FLEET_URL="`)
9190+
require.Contains(t, installJob.Product.CommandLine, `FLEET_SECRET="`)
9191+
91859192
// reply with success for both commands
91869193
msgID, err := d.GetCurrentMsgID()
91879194
require.NoError(t, err)

server/service/microsoft_mdm.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,28 @@ func (svc *Service) isFleetdPresentOnDevice(ctx context.Context, deviceID string
15011501
return true, nil
15021502
}
15031503

1504+
// generateWindowsEUAToken returns a Fleet-signed EUA token for the given Windows
1505+
// MDM device ID if the device enrolled with a valid Azure UPN
1506+
func (svc *Service) generateWindowsEUAToken(ctx context.Context, deviceID string) string {
1507+
if svc.wstepCertManager == nil {
1508+
return ""
1509+
}
1510+
device, err := svc.ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID)
1511+
if err != nil {
1512+
svc.logger.ErrorContext(ctx, "unable to fetch windows mdm enrollment for EUA token generation", "err", err, "device_id", deviceID)
1513+
return ""
1514+
}
1515+
if device == nil || !microsoft_mdm.IsValidUPN(device.MDMEnrollUserID) {
1516+
return ""
1517+
}
1518+
token, err := svc.wstepCertManager.NewEUAToken(device.MDMEnrollUserID, deviceID)
1519+
if err != nil {
1520+
svc.logger.ErrorContext(ctx, "unable to generate EUA token for fleetd install", "err", err, "device_id", deviceID)
1521+
return ""
1522+
}
1523+
return token
1524+
}
1525+
15041526
func (svc *Service) enqueueInstallFleetdCommand(ctx context.Context, deviceID string) error {
15051527
secrets, err := svc.ds.GetEnrollSecrets(ctx, nil)
15061528
if err != nil {
@@ -1530,6 +1552,11 @@ func (svc *Service) enqueueInstallFleetdCommand(ctx context.Context, deviceID st
15301552
addCommandUUID := uuid.NewString()
15311553
execCommandUUID := uuid.NewString()
15321554

1555+
euaTokenArg := ""
1556+
if token := svc.generateWindowsEUAToken(ctx, deviceID); token != "" {
1557+
euaTokenArg = ` EUA_TOKEN="` + token + `"`
1558+
}
1559+
15331560
rawAddCmd := []byte(`
15341561
<Add>
15351562
<CmdID>` + addCommandUUID + `</CmdID>
@@ -1562,7 +1589,7 @@ func (svc *Service) enqueueInstallFleetdCommand(ctx context.Context, deviceID st
15621589
<FileHash>` + fleetdMetadata.MSISha256 + `</FileHash>
15631590
</Validation>
15641591
<Enforcement>
1565-
<CommandLine>/quiet FLEET_URL="` + fleetURL + `" FLEET_SECRET="` + globalEnrollSecret + `" ENABLE_SCRIPTS="True"</CommandLine>
1592+
<CommandLine>/quiet FLEET_URL="` + fleetURL + `" FLEET_SECRET="` + globalEnrollSecret + `" ENABLE_SCRIPTS="True"` + euaTokenArg + `</CommandLine>
15661593
<TimeOut>10</TimeOut>
15671594
<RetryCount>1</RetryCount>
15681595
<RetryInterval>5</RetryInterval>

0 commit comments

Comments
 (0)