39272 Check entra tenant ID (#39780)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #39272

Changes file already added on another subtask

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] 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)

- [x] QA'd all new/changed functionality manually
This commit is contained in:
Jordan Montgomery
2026-02-12 19:27:35 -05:00
committed by GitHub
parent 518cd746b9
commit 6927bb6a8f
5 changed files with 159 additions and 19 deletions
+7 -2
View File
@@ -50,6 +50,8 @@ type TestWindowsMDMClient struct {
jwtSigningKey *rsa.PrivateKey
// jwtSigningKeyID is the ID to report in the header for the signing key
jwtSigningKeyID string
// Entra Tenant ID to include int he JWT
entraTenantID string
username string
password string
@@ -77,10 +79,11 @@ func TestWindowsMDMClientNotInOOBE() TestWindowsMDMClientOption {
}
}
func TestWindowsMDMClientWithSigningKey(signingKey *rsa.PrivateKey, signingKeyID string) TestWindowsMDMClientOption {
func TestWindowsMDMClientWithSigningKeyAndTenantID(signingKey *rsa.PrivateKey, signingKeyID, tenantID string) TestWindowsMDMClientOption {
return func(c *TestWindowsMDMClient) {
c.jwtSigningKey = signingKey
c.jwtSigningKeyID = signingKeyID
c.entraTenantID = tenantID
}
}
@@ -687,9 +690,11 @@ func (c *TestWindowsMDMClient) getToken() (binarySecToken string, tokenValueType
case fleet.WindowsMDMAutomaticEnrollmentType:
claims := &jwt.MapClaims{
"upn": c.TokenIdentifier,
"tid": "tenant_id",
"tid": c.entraTenantID,
"unique_name": "foo_bar",
"scp": "mdm_delegation",
"iss": "https://sts.windows.net/" + c.entraTenantID + "/",
"aud": c.fleetServerURL,
}
if c.jwtSigningKey == nil || c.jwtSigningKeyID == "" {
return "", "", errors.New("jwt signing key is not set")
+37
View File
@@ -24,6 +24,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
"github.com/smallstep/pkcs7"
)
@@ -67,6 +68,7 @@ type STSClaims struct {
type AzureData struct {
UPN string
Audience []string
TenantID string
UniqueName string
SCP string
@@ -320,6 +322,40 @@ func GetAzureAuthTokenClaims(ctx context.Context, tokenStr string) (AzureData, e
return AzureData{}, ctxerr.New(ctx, "invalid TenantID claim")
}
// Validate that tenant ID is a UUID and matches the issuer
_, err = uuid.Parse(tenantIDClaim)
if err != nil {
return AzureData{}, ctxerr.Wrap(ctx, err, "invalid TenantID claim format")
}
issuer, ok := claims["iss"].(string)
if !ok || len(issuer) == 0 {
return AzureData{}, ctxerr.New(ctx, "invalid Issuer claim")
}
// Depending on exactly how the Azure AD app is configured, the issuer claim
// may vary. Validate that the issuer contains the tenant ID.
issuerMatchesTenant := false
for _, expectedIssuer := range []string{fmt.Sprintf("https://sts.windows.net/%s/", tenantIDClaim), fmt.Sprintf("https://login.microsoftonline.com/%s/", tenantIDClaim)} {
if strings.HasPrefix(issuer, expectedIssuer) {
issuerMatchesTenant = true
break
}
}
if !issuerMatchesTenant {
return AzureData{}, ctxerr.New(ctx, "issuer claim does not match tenant ID")
}
audience := []string{}
singleAudience, ok := claims["aud"].(string)
if !ok {
multiAudience, ok := claims["aud"].([]string)
if ok {
audience = multiAudience
}
} else {
audience = append(audience, singleAudience)
}
// Get UniqueName claim
uniqueNameClaim, ok := claims["unique_name"].(string)
if !ok {
@@ -337,6 +373,7 @@ func GetAzureAuthTokenClaims(ctx context.Context, tokenStr string) (AzureData, e
TenantID: tenantIDClaim,
UniqueName: uniqueNameClaim,
SCP: azureSCPClaim,
Audience: audience,
}, nil
}
@@ -458,13 +458,18 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsWindows() {
t.Skip("wipe tests are not supported for windows automatic enrollment until we fix #TODO")
}
tenantID := uuid.New().String()
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp)
err := s.ds.ApplyEnrollSecrets(context.Background(), nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
host := createOrbitEnrolledHost(t, "windows", "windows_automatic", s.ds)
azureMail := "foo.bar.baz@example.com"
device := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, azureMail, mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, defaultFakeJWTKeyID))
device := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, azureMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID))
device.HardwareID = host.UUID
device.DeviceID = host.UUID
require.NoError(t, device.Enroll())
+65 -14
View File
@@ -1610,23 +1610,23 @@ func enrollWindowsHostInMDMViaOrbit(t *testing.T, host *fleet.Host, ds fleet.Dat
}
// Simulates a host being orbit enrolled first then an MDM enrollment coming via the settings app
func (s *integrationMDMTestSuite) createWindowsHostThenEnrollMDMViaSettingsApp(fleetServerURL, email string) (*fleet.Host, *mdmtest.TestWindowsMDMClient) {
func (s *integrationMDMTestSuite) createWindowsHostThenEnrollMDMViaSettingsApp(fleetServerURL, email, tenantID string) (*fleet.Host, *mdmtest.TestWindowsMDMClient) {
host := createOrbitEnrolledHost(s.T(), "windows", uuid.NewString(), s.ds)
mdmDevice := s.enrollWindowsMDMViaSettingsApp(fleetServerURL, email)
mdmDevice := s.enrollWindowsMDMViaSettingsApp(fleetServerURL, email, tenantID)
return host, mdmDevice
}
// Note that this method only creates the MDM Enrollment but it will still need to be linked to the host record either
// via DS methods or by simualting a refetch.
func (s *integrationMDMTestSuite) enrollWindowsMDMViaSettingsApp(fleetServerURL, email string) *mdmtest.TestWindowsMDMClient {
mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(fleetServerURL, email, mdmtest.TestWindowsMDMClientNotInOOBE(), mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, defaultFakeJWTKeyID))
func (s *integrationMDMTestSuite) enrollWindowsMDMViaSettingsApp(fleetServerURL, email, tenantID string) *mdmtest.TestWindowsMDMClient {
mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(fleetServerURL, email, mdmtest.TestWindowsMDMClientNotInOOBE(), mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID))
err := mdmDevice.Enroll()
require.NoError(s.T(), err)
return mdmDevice
}
func (s *integrationMDMTestSuite) enrollWindowsHostInMDMViaAutopilot(fleetServerURL, email string) *mdmtest.TestWindowsMDMClient {
mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(fleetServerURL, email, mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, defaultFakeJWTKeyID))
func (s *integrationMDMTestSuite) enrollWindowsHostInMDMViaAutopilot(fleetServerURL, email, tenantID string) *mdmtest.TestWindowsMDMClient {
mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(fleetServerURL, email, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID))
err := mdmDevice.Enroll()
require.NoError(s.T(), err)
return mdmDevice
@@ -7944,13 +7944,20 @@ func (s *integrationMDMTestSuite) TestValidGetPoliciesRequestWithDeviceToken() {
func (s *integrationMDMTestSuite) TestValidGetPoliciesRequestWithAzureToken() {
t := s.T()
tenantID := uuid.New().String()
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp)
// Preparing the GetPolicies Request message with Azure JWT token
// Preparing the SecurityToken Request message with Azure JWT token
claims := &jwt.MapClaims{
"upn": "fleetie@example.com",
"tid": "tenant_id",
"tid": tenantID,
"iss": "https://sts.windows.net/" + tenantID + "/",
"unique_name": "foo_bar",
"scp": "mdm_delegation",
"aud": s.server.URL + microsoft_mdm.MDE2PolicyPath,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
@@ -8120,12 +8127,19 @@ func (s *integrationMDMTestSuite) TestValidRequestSecurityTokenRequestWithDevice
func (s *integrationMDMTestSuite) TestValidRequestSecurityTokenRequestWithAzureToken() {
t := s.T()
tenantID := uuid.New().String()
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp)
// Preparing the SecurityToken Request message with Azure JWT token
claims := &jwt.MapClaims{
"upn": "fleetie@example.com",
"tid": "tenant_id",
"tid": tenantID,
"iss": "https://sts.windows.net/" + tenantID + "/",
"unique_name": "foo_bar",
"scp": "mdm_delegation",
"aud": s.server.URL,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
@@ -8702,8 +8716,12 @@ func (s *integrationMDMTestSuite) TestWindowsAutomaticEnrollmentCommands() {
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
tenantID := uuid.New().String()
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp)
azureMail := "foo.bar.baz@example.com"
d := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, azureMail, mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, defaultFakeJWTKeyID))
d := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, azureMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID))
require.NoError(t, d.Enroll())
checkinAndAck := func(expectFleetdCmds bool) {
@@ -8806,20 +8824,49 @@ func (s *integrationMDMTestSuite) TestWindowsAzureInitiatedBadKeys() {
_, badKey, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
tenantID := uuid.New().String()
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp)
autopilotUserMail := "swan@example.com"
// Bad key
mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKey(badKey, defaultFakeJWTKeyID))
mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(badKey, defaultFakeJWTKeyID, tenantID))
err = mdmDevice.Enroll()
require.Error(s.T(), err)
// Good key but wrong ID
mdmDevice = mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, "bad-key-id"))
mdmDevice = mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, "bad-key-id", tenantID))
err = mdmDevice.Enroll()
require.Error(s.T(), err)
// Happy path to ensure the setup is correct
mdmDevice = mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKey(s.jwtSigningKey, defaultFakeJWTKeyID))
mdmDevice = mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID))
err = mdmDevice.Enroll()
require.NoError(s.T(), err)
}
func (s *integrationMDMTestSuite) TestWindowsAzureInitiatedTenantIDs() {
t := s.T()
ctx := context.Background()
// define a global enroll secret
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
tenantID := uuid.New().String()
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp)
autopilotUserMail := "swan@example.com"
// Bad entra tenant ID
mdmDevice := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, uuid.New().String()))
err = mdmDevice.Enroll()
require.Error(s.T(), err)
// Happy path to ensure the setup is correct
mdmDevice = mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, autopilotUserMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID))
err = mdmDevice.Enroll()
require.NoError(s.T(), err)
}
@@ -8832,6 +8879,10 @@ func (s *integrationMDMTestSuite) TestWindowsAzureInitiatedEnrollmentAndMapping(
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
tenantID := uuid.New().String()
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp)
team, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: "team1_" + t.Name(),
Description: "desc team1_" + t.Name(),
@@ -8840,11 +8891,11 @@ func (s *integrationMDMTestSuite) TestWindowsAzureInitiatedEnrollmentAndMapping(
// Enroll another host to ensure the wires don't get crossed somehow
autopilotUserMail := "swan@example.com"
autopilotDevice := s.enrollWindowsHostInMDMViaAutopilot(s.server.URL, autopilotUserMail)
autopilotDevice := s.enrollWindowsHostInMDMViaAutopilot(s.server.URL, autopilotUserMail, tenantID)
require.NoError(t, autopilotDevice.Enroll())
settingsAppUserMail := "fleetie@example.com"
settingsAppHost, settingsAppDevice := s.createWindowsHostThenEnrollMDMViaSettingsApp(s.server.URL, settingsAppUserMail)
settingsAppHost, settingsAppDevice := s.createWindowsHostThenEnrollMDMViaSettingsApp(s.server.URL, settingsAppUserMail, tenantID)
require.NoError(t, settingsAppDevice.Enroll())
// Transfer the host to the team. Ensure it doesn't wind up in "No team" at the end
+44 -2
View File
@@ -1007,7 +1007,6 @@ func (svc *Service) authBinarySecurityToken(ctx context.Context, authToken *flee
// Validating the Binary Security Token Type used on Automatic Enrollments (returned by STS Auth Endpoint)
if binSecToken.Type == mdm_types.WindowsMDMAutomaticEnrollmentType {
upnToken, err := svc.wstepCertManager.GetSTSAuthTokenUPNClaim(binSecToken.Payload.AuthToken)
if err != nil {
return "", "", ctxerr.Wrap(ctx, err, "issue retrieving UPN from Auth token")
@@ -1020,6 +1019,20 @@ func (svc *Service) authBinarySecurityToken(ctx context.Context, authToken *flee
// Validating the Binary Security Token Type used on Automatic Enrollments
if authToken.IsAzureJWTToken() {
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return "", "", ctxerr.Wrap(ctx, err, "retrieving app config for auth token validation")
}
entraTenantIDs := appConfig.MDM.WindowsEntraTenantIDs.Value
if len(entraTenantIDs) == 0 {
return "", "", ctxerr.New(ctx, "no entra tenant IDs configured for automatic enrollment")
}
expectedURL := appConfig.ServerSettings.ServerURL
expectedURLParsed, err := url.Parse(expectedURL)
if err != nil {
return "", "", ctxerr.Wrap(ctx, err, "parsing server URL for auth token validation")
}
// Validate the JWT Auth token by retreving its claims
tokenData, err := microsoft_mdm.GetAzureAuthTokenClaims(ctx, authToken.Content)
@@ -1027,11 +1040,40 @@ func (svc *Service) authBinarySecurityToken(ctx context.Context, authToken *flee
return "", "", fmt.Errorf("binary security token claim failed: %v", err)
}
hasExpectedAudience := false
for _, aud := range tokenData.Audience {
audURL, err := url.Parse(aud)
// The Audience may have multiple values and not everything in the aud will be a URL and that's OK
if err != nil {
continue
}
if audURL.Host == expectedURLParsed.Host {
hasExpectedAudience = true
break
}
}
if !hasExpectedAudience {
// Log bad audiences here for debugging
level.Error(svc.logger).Log(
"msg", "unexpected token audience in AzureAD Binary Security Token",
"expected_host", expectedURLParsed.Host,
"token_audiences", strings.Join(tokenData.Audience, ","),
)
return "", "", ctxerr.Errorf(ctx, "token audience is not authorized")
}
if !slices.Contains(entraTenantIDs, tokenData.TenantID) {
level.Error(svc.logger).Log(
"msg", "unexpected token tenant in AzureAD Binary Security Token",
"token_tenant", tokenData.TenantID,
)
return "", "", ctxerr.New(ctx, "token tenant is not authorized")
}
// No errors, token is authorized
return tokenData.UPN, "", nil
}
return "", "", errors.New("token is not authorized")
return "", "", ctxerr.New(ctx, "token is not authorized")
}
// ProcessMDMMicrosoftDiscovery handles the Discovery message validation and response