Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.
23 changes: 10 additions & 13 deletions ee/server/service/mdm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion orbit/cmd/orbit/orbit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
17 changes: 17 additions & 0 deletions server/fleet/mdm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
2 changes: 1 addition & 1 deletion server/service/frontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
45 changes: 45 additions & 0 deletions server/service/integration_mdm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
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, fleet.SSOInitiatorOTAEnroll, u.Query().Get("initiator"))
}

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

Expand Down
71 changes: 70 additions & 1 deletion server/service/testing_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Comment thread
MagnusHJensen marked this conversation as resolved.
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)
Comment thread
MagnusHJensen marked this conversation as resolved.
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)
Comment thread
MagnusHJensen marked this conversation as resolved.
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)
Comment thread
MagnusHJensen marked this conversation as resolved.
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)
Expand Down
Loading