Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/45024-android-sso-missing-profile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed MDM SSO callback returning "missing profile" error for Android enrollment when Apple MDM is not configured.
6 changes: 6 additions & 0 deletions ee/server/service/mdm.go
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,12 @@ func (svc *Service) mdmSSOHandleCallbackAuth(
return "", idpAcc.UUID, eulaToken, originalURL, ssoRequestData, nil
}

// OTA enrollments (e.g. Android, BYOD iPhone/iPad) don't need the Apple
// DEP automatic enrollment profile.
if strings.HasPrefix(originalURL, "/enroll?") {
return "", idpAcc.UUID, eulaToken, originalURL, ssoRequestData, nil
}

var depProfToken string
// For automatic enrollments, get the automatic profile to access the authentication token.
if ssoRequestData.Initiator != "setup_experience" {
Expand Down
49 changes: 49 additions & 0 deletions server/service/integration_mdm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20400,6 +20400,55 @@ func (s *integrationMDMTestSuite) TestBYODEnrollmentWithIdPEnabled() {
require.True(t, strings.HasPrefix(location, testSAMLIDPBaseURL+"/simplesaml/"))
}

// TestOTAEnrollSSOWithoutAppleDEPProfile verifies that OTA enrollment SSO
// (used by Android and BYOD iPhone/iPad) succeeds even when no Apple DEP
// automatic enrollment profile exists. This is a regression test for #45024
// where the SSO callback returned "missing profile" on Android-only instances.
func (s *integrationMDMTestSuite) TestOTAEnrollSSOWithoutAppleDEPProfile() {
t := s.T()
ctx := t.Context()

if !hasBuildTag("full") {
t.Skip("This test requires running with -tags full")
}

s.setSkipWorkerJobs(t)
s.setUpMDMSSO(t, false)

// Create a team with IdP (end user authentication) enabled and an enroll secret.
teamIdP, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team with idp for ota sso test"})
require.NoError(t, err)
teamIdP.Config.MDM.MacOSSetup.EnableEndUserAuthentication = true
_, err = s.ds.SaveTeam(ctx, teamIdP)
require.NoError(t, err)
err = s.ds.ApplyEnrollSecrets(ctx, &teamIdP.ID, []*fleet.EnrollSecret{{Secret: "ota-sso-test"}})

Check failure on line 20424 in server/service/integration_mdm_test.go

View workflow job for this annotation

GitHub Actions / lint-incremental (windows-latest)

G101: Potential hardcoded credentials (gosec)

Check failure on line 20424 in server/service/integration_mdm_test.go

View workflow job for this annotation

GitHub Actions / lint-incremental (ubuntu-latest)

G101: Potential hardcoded credentials (gosec)
require.NoError(t, err)

// Remove any Apple DEP automatic enrollment profiles to simulate an
// instance where Apple MDM is not configured (Android-only).
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
Comment thread
MagnusHJensen marked this conversation as resolved.
_, err := q.ExecContext(ctx, "DELETE FROM mdm_apple_enrollment_profiles")
return err
})

// Perform the full OTA enrollment SSO flow: GET /enroll → IdP login → callback.
// Before the fix for #45024, this would fail with "missing profile" because the
// callback tried to fetch the (now-deleted) Apple DEP enrollment profile.
res := s.LoginOTAEnrollSSOUser("sso_user", "user123#", "ota-sso-test")
require.Equal(t, http.StatusSeeOther, res.StatusCode)
location := res.Header.Get("Location")
require.NotEmpty(t, location)

u, err := url.Parse(location)
require.NoError(t, err)

// The callback should redirect back to the /enroll page (not to ?error=true).
require.True(t, strings.HasPrefix(u.Path, "/enroll"), "expected redirect to /enroll, got: %s", location)
require.Empty(t, u.Query().Get("error"), "expected no error in redirect, got: %s", location)
require.NotEmpty(t, u.Query().Get("enrollment_reference"), "expected enrollment_reference in redirect")
require.Equal(t, "ota_enroll", u.Query().Get("initiator"))
}

func (s *integrationMDMTestSuite) TestIOSiPadOSRefetch() {
ctx := s.T().Context()

Expand Down
61 changes: 61 additions & 0 deletions server/service/testing_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,67 @@ func (ts *withServer) LoginMDMSSOUser(username, password string) *http.Response
return res
}

// LoginOTAEnrollSSOUser initiates the OTA enrollment SSO flow by hitting
// /enroll?enroll_secret=... (as an Android or BYOD device would), follows the
// SAML login at the IdP, and posts the SAMLResponse back to the MDM SSO
// callback. Returns the callback response (a redirect).
func (ts *withServer) LoginOTAEnrollSSOUser(username, password, enrollSecret string) *http.Response {
t := ts.s.T()

if _, ok := os.LookupEnv("SAML_IDP_TEST"); !ok {
t.Skip("SSO tests are disabled")
}

Comment thread
MagnusHJensen marked this conversation as resolved.
cookieSecure = false
jar, err := cookiejar.New(nil)
require.NoError(t, err)

client := fleethttp.NewClient(
fleethttp.WithFollowRedir(false),
fleethttp.WithCookieJar(jar),
)

// Step 1: GET /enroll?enroll_secret=... → 303 redirect to IdP (sets SSO cookie)
enrollURL := ts.server.URL + "/enroll?enroll_secret=" + url.QueryEscape(enrollSecret)
resp, err := client.Get(enrollURL)
require.NoError(t, err)
require.Equal(t, http.StatusSeeOther, resp.StatusCode)
Comment thread
MagnusHJensen marked this conversation as resolved.
idpURL := resp.Header.Get("Location")
require.NotEmpty(t, idpURL, "expected redirect to IdP")

// Step 2: Follow IdP redirect to get the login page
resp, err = client.Get(idpURL)
require.NoError(t, err)
Comment thread
MagnusHJensen marked this conversation as resolved.

// Step 3: Extract AuthState and submit login credentials
parsed, err := url.Parse(resp.Header.Get("Location"))
require.NoError(t, err)
data := url.Values{
"username": {username},
"password": {password},
"AuthState": {parsed.Query().Get("AuthState")},
}
resp, err = client.PostForm(parsed.Scheme+"://"+parsed.Host+parsed.Path, data)
Comment thread
MagnusHJensen marked this conversation as resolved.
require.NoError(t, err)

// Step 4: Extract SAMLResponse from the IdP HTML form
defer resp.Body.Close()
Comment thread
MagnusHJensen marked this conversation as resolved.
Outdated
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)

re := regexp.MustCompile(`name="SAMLResponse" value="([^\s]*)" />`)
matches := re.FindSubmatch(body)
require.NotEmptyf(t, matches, "callback HTML doesn't contain a SAMLResponse value, got body: %s", body)
samlResponse := string(matches[1])

// Step 5: POST SAMLResponse to Fleet's MDM SSO callback (cookie jar carries the SSO session)
callbackURL := ts.server.URL + "/api/v1/fleet/mdm/sso/callback?SAMLResponse=" + url.QueryEscape(samlResponse)
resp, err = client.Post(callbackURL, "application/x-www-form-urlencoded", nil)
require.NoError(t, err)

return resp
}

func (ts *withServer) LoginAccountDrivenEnrollUser(username, password string) *http.Response {
requestParams := initiateMDMSSORequest{
Initiator: "account_driven_enroll",
Expand Down
Loading