diff --git a/changes/45024-android-sso-missing-profile b/changes/45024-android-sso-missing-profile new file mode 100644 index 00000000000..da7cb672682 --- /dev/null +++ b/changes/45024-android-sso-missing-profile @@ -0,0 +1 @@ +Fixed MDM SSO callback returning "missing profile" error for Android enrollment when Apple MDM is not configured. diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index b0261896eed..73aba5742f1 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -887,12 +887,12 @@ func (svc *Service) InitiateMDMSSO(ctx context.Context, initiator, customOrigina originalURL := "/" switch initiator { - case "account_driven_enroll": + case fleet.SSOInitiatorAccountDrivenEnroll: // originalURL is unused in the Setup Experience initiated MDM flow // however because we need slightly different behavior for account driven // enrollment we use it to signal proper behavior on the callback. originalURL = appleMDMAccountDrivenEnrollmentUrl - case "ota_enroll": + case fleet.SSOInitiatorOTAEnroll: // for ota_enroll, we support the custom original URL argument, as the // enroll secret used to enroll varies. Other initiators do not support // a custom original URL (and should receive an empty string). @@ -928,7 +928,7 @@ func (svc *Service) MDMSSOCallback(ctx context.Context, sessionID string, samlRe return apple_mdm.FleetUISSOCallbackPath + "?error=true", "" } - if !strings.HasPrefix(originalURL, "/enroll?") && ssoRequestData.Initiator != "setup_experience" { + if !strings.HasPrefix(originalURL, "/enroll?") && ssoRequestData.Initiator != fleet.SSOInitiatorOrbitSetupExperience { // for flows other than the /enroll BYOD, we have to ensure that Apple MDM // is enabled (this was previously done in a middleware on the route, but // we do it here now so the middleware is disabled for the BYOD flow, which @@ -941,12 +941,14 @@ func (svc *Service) MDMSSOCallback(ctx context.Context, sessionID string, samlRe } q := url.Values{ - "profile_token": {profileToken}, "enrollment_reference": {enrollmentRef}, } if eulaToken != "" { q.Add("eula_token", eulaToken) } + if profileToken != "" { + q.Add("profile_token", profileToken) + } q.Add("initiator", ssoRequestData.Initiator) @@ -1111,9 +1113,9 @@ func (svc *Service) mdmSSOHandleCallbackAuth( return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "retrieving new account data from IdP") } - // If the initiator is "setup_experience", we can insert the host idp account record + // If the initiator is setup_experience, we can insert the host idp account record // right away, as the host uuid is provided in the SSO request data. - if ssoRequestData.Initiator == "setup_experience" && ssoRequestData.HostUUID != "" { + if ssoRequestData.Initiator == fleet.SSOInitiatorOrbitSetupExperience && ssoRequestData.HostUUID != "" { err = svc.ds.AssociateHostMDMIdPAccountDB(ctx, ssoRequestData.HostUUID, idpAcc.UUID) if err != nil { return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "saving host-account link from IdP") @@ -1129,14 +1131,9 @@ func (svc *Service) mdmSSOHandleCallbackAuth( eulaToken = eula.Token } - // If this is account driven enrollment there is no need to fetch the profile - if originalURL == appleMDMAccountDrivenEnrollmentUrl { - 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" { + var depProfToken string + if ssoRequestData.Initiator == fleet.SSOInitiatorAppleMDMSSO { depProf, err := svc.getAutomaticEnrollmentProfile(ctx) if err != nil { return "", "", "", "", sso.SSORequestData{}, ctxerr.Wrap(ctx, err, "listing profiles") diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 33b0ea78cb1..b10dce07d83 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -1149,7 +1149,7 @@ func orbitAction(c *cli.Context) error { // Set the function that will be called to open the SSO window if an enroll // request returns an "end user authentication required" error. orbitClient.SetOpenSSOWindowFunc(func() error { - err = openBrowserWindow(fleetURL + "/mdm/sso?initiator=setup_experience&host_uuid=" + orbitHostInfo.HardwareUUID) + err = openBrowserWindow(fleetURL + "/mdm/sso?initiator=" + fleet.SSOInitiatorOrbitSetupExperience + "&host_uuid=" + orbitHostInfo.HardwareUUID) if err != nil { return fmt.Errorf("opening browser: %w", err) } diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 37d58a0013b..3f4a977c405 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -1324,3 +1324,20 @@ type NanoMDMEnrollmentDetails struct { HardwareAttested bool `db:"hardware_attested"` UnlockToken *string `db:"unlock_token"` } + +// MDM SSO initiator constants identify which enrollment flow initiated the SSO +// authentication. These values are stored in the SSO session and used in the +// callback to determine the correct behavior. +const ( + // SSOInitiatorOTAEnroll is used for OTA/BYOD enrollment flows (Android, + // iPhone, iPad) initiated from the /enroll page. + SSOInitiatorOTAEnroll = "ota_enroll" + // SSOInitiatorOrbitSetupExperience is used when the Orbit agent opens the SSO + // browser window during the macOS Setup Assistant, Windows enrollment or Linux enrollment. + SSOInitiatorOrbitSetupExperience = "setup_experience" + // SSOInitiatorAccountDrivenEnroll is used for Apple's native account-driven + // MDM enrollment flow. + SSOInitiatorAccountDrivenEnroll = "account_driven_enroll" + // SSOInitiatorAppleMDMSSO is used for automatic MDM Apple enrollment SSO flow. + SSOInitiatorAppleMDMSSO = "mdm_sso" +) diff --git a/server/service/frontend.go b/server/service/frontend.go index 6ce4d324303..57205469b10 100644 --- a/server/service/frontend.go +++ b/server/service/frontend.go @@ -245,7 +245,7 @@ func initiateOTAEnrollSSO(svc fleet.Service, w http.ResponseWriter, r *http.Requ if r.URL.Query().Get("fully_managed") == "true" { requestURL += "&fully_managed=true" } - ssnID, ssnDurationSecs, idpURL, err := svc.InitiateMDMSSO(r.Context(), "ota_enroll", requestURL, "") + ssnID, ssnDurationSecs, idpURL, err := svc.InitiateMDMSSO(r.Context(), fleet.SSOInitiatorOTAEnroll, requestURL, "") if err != nil { return err } diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index b74a89b9025..156246f5dda 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -20400,6 +20400,51 @@ 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() + + 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"}}) //nolint:gosec // test credential + 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 { + _, 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, fleet.SSOInitiatorOTAEnroll, u.Query().Get("initiator")) +} + func (s *integrationMDMTestSuite) TestIOSiPadOSRefetch() { ctx := s.T().Context() diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 6796890fd2d..9bd0619d236 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -454,9 +454,78 @@ 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") + } + + prevCookieSecure := cookieSecure + t.Cleanup(func() { + cookieSecure = prevCookieSecure + }) + 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) + idpURL := resp.Header.Get("Location") + require.NotEmpty(t, idpURL, "expected redirect to IdP") + require.NoError(t, resp.Body.Close()) + + // Step 2: Follow IdP redirect to get the login page + resp, err = client.Get(idpURL) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + // 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) + require.NoError(t, err) + + // Step 4: Extract SAMLResponse from the IdP HTML form + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + 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) + require.NoError(t, resp.Body.Close()) + + return resp +} + func (ts *withServer) LoginAccountDrivenEnrollUser(username, password string) *http.Response { requestParams := initiateMDMSSORequest{ - Initiator: "account_driven_enroll", + Initiator: fleet.SSOInitiatorAccountDrivenEnroll, UserIdentifier: username + "@example.com", } body, err := json.Marshal(requestParams)