Fix MDM SSO callback 'missing profile' error for Android enrollment (#45046)
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"`) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. [](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45046) <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
This commit is contained in:
co-authored by
Magnus Jensen
parent
81bc4a3f92
commit
0276662545
@@ -0,0 +1 @@
|
||||
Fixed MDM SSO callback returning "missing profile" error for Android enrollment when Apple MDM is not configured.
|
||||
+10
-13
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user