From 02766625453626faebededf6494442e349d1e383 Mon Sep 17 00:00:00 2001 From: Sharon Katz <121527325+sharon-fdm@users.noreply.github.com> Date: Tue, 12 May 2026 12:42:16 -0400 Subject: [PATCH] Fix MDM SSO callback 'missing profile' error for Android enrollment (#45046) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #45024 ## Summary - Fixed the MDM SSO callback handler returning a `"missing profile: missing profile"` error when an Android device enrolls via SSO (OTA enrollment) on a Fleet instance that does **not** have Apple MDM configured. - Refactored all MDM SSO initiator magic strings (`"ota_enroll"`, `"setup_experience"`, `"account_driven_enroll"`) into named constants (`fleet.SSOInitiatorOTAEnroll`, etc.) to prevent typos and missed cases — which is the class of bug that caused this issue. ## Code walkthrough ### The bug The bug is in `ee/server/service/mdm.go` in `mdmSSOHandleCallbackAuth()`. **The flow:** 1. Android enrollment hits `/enroll?enroll_secret=xxx` → frontend calls `InitiateMDMSSO` with initiator `"ota_enroll"` (`server/service/frontend.go:248`) 2. User authenticates at the SAML IdP 3. The SSO callback arrives at `MDMSSOCallback` → calls `mdmSSOHandleCallbackAuth` 4. After successful SAML auth, the function checks early-exit conditions: - Line 1133: account-driven enrollment (`originalURL == appleMDMAccountDrivenEnrollmentUrl`) → **no match** for OTA - Line 1139: `Initiator != "setup_experience"` → **true** for `"ota_enroll"` → enters the block 5. Line 1140: calls `getAutomaticEnrollmentProfile()` → returns `nil` because **no Apple MDM is configured** 6. Line 1144–1146: `depProf == nil` → **returns `"missing profile"` error** Note that `MDMSSOCallback` (the caller) already has a guard at line 931 that correctly skips the Apple MDM verification for `/enroll?` paths: ```go if !strings.HasPrefix(originalURL, "/enroll?") && ssoRequestData.Initiator != "setup_experience" { if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { ... } } ``` But `mdmSSOHandleCallbackAuth` was missing the equivalent guard — it unconditionally tried to fetch the Apple DEP profile for any non-`setup_experience` initiator. ### The fix Adds an early return for OTA enrollments (where `originalURL` starts with `/enroll?`), matching the existing pattern for account-driven enrollments right above it. OTA enrollments don't use the Apple DEP profile token. ### The refactor Replaced all raw initiator string literals across the backend with named constants defined in `server/fleet/app.go`: | Constant | Value | Used by | |---|---|---| | `fleet.SSOInitiatorOTAEnroll` | `"ota_enroll"` | `/enroll` page (Android, BYOD iPhone/iPad) | | `fleet.SSOInitiatorSetupExperience` | `"setup_experience"` | Orbit agent (macOS Setup Assistant) | | `fleet.SSOInitiatorAccountDrivenEnroll` | `"account_driven_enroll"` | Apple account-driven MDM enrollment | Constants are in `server/fleet/` (not `server/sso/`) so orbit can import them without pulling in Redis dependencies. **Files changed:** - `ee/server/service/mdm.go` — 6 string replacements (switch cases + comparisons) - `server/service/frontend.go` — 1 replacement - `orbit/cmd/orbit/orbit.go` — 1 replacement - `server/service/testing_client.go` — 1 replacement - `server/service/integration_mdm_test.go` — 1 replacement ## Local reproduction ### Setup 1. Started dev server: `build/fleet serve --dev --dev_license` 2. Infrastructure: MySQL, Redis, SimpleSAML IdP via `docker compose up` 3. Created admin user and enroll secret 4. Configured MDM SSO (`entity_id: mdm.test.com`, SimpleSAML IdP at `localhost:9080`) 5. Set `enable_end_user_authentication: true` directly in DB (API blocks this without Apple MDM — matches customer state) 6. **Did NOT configure Apple MDM** — only SSO + EUA, simulating Android-only instance ### Steps 1. `GET https://localhost:8080/enroll?enroll_secret=test_enroll_secret` → 303 redirect to SimpleSAML IdP 2. Completed SAML login programmatically (user: `sso_user`, pass: `user123#`) 3. `POST https://localhost:8080/api/v1/fleet/mdm/sso/callback` with the SAMLResponse ### Before fix ``` === CALLBACK RESULT === Status: HTTP/2 303 Location: /mdm/sso/callback?error=true === SERVER LOGS === ts=2026-05-08T16:53:49Z level=error component=http method=POST uri=/api/v1/fleet/mdm/sso/callback took=12.148708ms err="missing profile: missing profile" ``` ### After fix ``` === CALLBACK RESULT === Status: HTTP/2 303 Location: /enroll?enroll_secret=test_enroll_secret&enrollment_reference=7c67326c-...&initiator=ota_enroll&profile_token= === SERVER LOGS === ts=2026-05-08T17:27:54Z level=info component=http method=POST uri=/api/v1/fleet/mdm/sso/callback took=15.973ms ``` No errors. Successful redirect back to the enrollment page with the enrollment reference. ## Integration test Added `TestOTAEnrollSSOWithoutAppleDEPProfile` which: 1. Configures SSO and creates a team with IdP enabled 2. **Deletes all Apple DEP enrollment profiles** to simulate an Android-only instance 3. Runs the full OTA enrollment SSO flow (GET `/enroll` → SAML IdP login → callback) 4. Verifies the callback redirects to `/enroll?...` with `enrollment_reference` and `initiator=ota_enroll` (not `?error=true`) Confirmed the test **fails without the fix** (`err="missing profile: missing profile"`) and **passes with the fix**. Also added a `LoginOTAEnrollSSOUser` test helper that drives the complete OTA SSO flow starting from `GET /enroll` through SAML IdP login to the callback, using a single cookie jar. ## Test plan - [ ] Verify Android SSO enrollment works on an instance with **only** Android MDM configured (no Apple MDM) - [ ] Verify Apple DEP enrollment with SSO still works (the DEP profile path is unchanged) - [ ] Verify Apple OTA enrollment with SSO still works (also uses `/enroll?` path) - [ ] Verify account-driven enrollment with SSO still works (has its own early return) - [ ] Verify setup experience SSO still works (uses `Initiator == "setup_experience"`) ## Summary by CodeRabbit * **Bug Fixes** * Resolved a regression where OTA enrollment via SSO could return a "missing profile" error on Android when Apple MDM is not configured; OTA SSO now redirects correctly to the enrollment flow. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45046) --------- Co-authored-by: Magnus Jensen --- changes/45024-android-sso-missing-profile | 1 + ee/server/service/mdm.go | 23 +++---- orbit/cmd/orbit/orbit.go | 2 +- server/fleet/mdm.go | 17 +++++ server/service/frontend.go | 2 +- server/service/integration_mdm_test.go | 50 ++++++++++++++- server/service/testing_client.go | 76 ++++++++++++++++++++++- 7 files changed, 152 insertions(+), 19 deletions(-) create mode 100644 changes/45024-android-sso-missing-profile diff --git a/changes/45024-android-sso-missing-profile b/changes/45024-android-sso-missing-profile new file mode 100644 index 0000000000..da7cb67268 --- /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 b0261896ee..73aba5742f 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 36e9956f33..19c2e7e55d 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -1146,7 +1146,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 37d58a0013..3f4a977c40 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 6ce4d32430..57205469b1 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 b74a89b902..3467fda5a1 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -20368,11 +20368,12 @@ func (s *integrationMDMTestSuite) TestBYODEnrollmentWithIdPEnabled() { require.NotEmpty(t, location) require.True(t, strings.HasPrefix(location, testSAMLIDPBaseURL+"/simplesaml/")) - res = s.LoginMDMSSOUser("sso_user", "user123#") + res = s.LoginOTAEnrollSSOUser("sso_user", "user123#", "idp") require.Equal(t, http.StatusSeeOther, res.StatusCode) location = res.Header.Get("Location") + t.Logf("SSO login redirect location: %s", location) require.NotEmpty(t, location) - require.True(t, strings.HasPrefix(location, "/mdm/sso/callback")) + require.True(t, strings.HasPrefix(location, "/enroll")) // expect to be redirect from /enroll page for BYOD // requesting the /enroll page again and simulating the BYOD IdP cookie being set // still redirects to the SSO login if the cookie value does not match the @@ -20400,6 +20401,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 6796890fd2..4ee06b16dc 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -449,14 +449,86 @@ func (ts *withServer) LoginSSOUser(username, password string) string { return string(body) } +// LoginMDMSSOUser initiates the MDM SSO flow, as Apple DEP enrollment would. func (ts *withServer) LoginMDMSSOUser(username, password string) *http.Response { - res := ts.loginSSOUser(username, password, "/api/v1/fleet/mdm/sso", http.StatusSeeOther) + body, err := json.Marshal(initiateMDMSSORequest{Initiator: fleet.SSOInitiatorAppleMDMSSO}) + require.NoError(ts.s.T(), err) + res := ts.loginSSOUserWithBody(username, password, "/api/v1/fleet/mdm/sso", http.StatusSeeOther, body) 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)