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.

[![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)
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
This commit is contained in:
Sharon Katz
2026-05-12 12:42:16 -04:00
committed by GitHub
co-authored by Magnus Jensen
parent 81bc4a3f92
commit 0276662545
7 changed files with 152 additions and 19 deletions
@@ -0,0 +1 @@
Fixed MDM SSO callback returning "missing profile" error for Android enrollment when Apple MDM is not configured.
+10 -13
View File
@@ -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")
+1 -1
View File
@@ -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)
}
+17
View File
@@ -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"
)
+1 -1
View File
@@ -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
}
+48 -2
View File
@@ -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()
+74 -2
View File
@@ -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)