Skip to content

Fix MDM SSO callback 'missing profile' error for Android enrollment - #45046

Merged
JordanMontgomery merged 6 commits into
mainfrom
fix-45024-android-sso-missing-profile
May 12, 2026
Merged

Fix MDM SSO callback 'missing profile' error for Android enrollment#45046
JordanMontgomery merged 6 commits into
mainfrom
fix-45024-android-sso-missing-profile

Conversation

@sharon-fdm

@sharon-fdm sharon-fdm commented May 8, 2026

Copy link
Copy Markdown
Collaborator

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 == nilreturns "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:

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

…45024)

When an Android device enrolls via SSO (OTA enrollment), the callback
handler tried to fetch an Apple DEP automatic enrollment profile that
doesn't exist when only Android MDM is configured. Add an early return
for OTA enrollments that skips the DEP profile fetch, matching the
existing guard pattern for account-driven enrollments.
Adds TestOTAEnrollSSOWithoutAppleDEPProfile which verifies the full OTA
enrollment SSO flow succeeds even when no Apple DEP automatic enrollment
profile exists (simulating an Android-only instance). Also adds
LoginOTAEnrollSSOUser test helper that drives the complete OTA SSO flow
starting from GET /enroll through SAML IdP login to the callback.
@sharon-fdm
sharon-fdm marked this pull request as ready for review May 8, 2026 17:42
@sharon-fdm
sharon-fdm requested a review from a team as a code owner May 8, 2026 17:42
Copilot AI review requested due to automatic review settings May 8, 2026 17:42

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@coderabbitai

coderabbitai Bot commented May 8, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This PR introduces SSO initiator constants and updates MDM SSO initiation and callback logic to dispatch by initiator. The callback now omits profile_token when empty and skips automatic DEP profile lookup for OTA enrollments. Frontend and Orbit code use the new constants. A test helper (LoginOTAEnrollSSOUser) and an integration test (TestOTAEnrollSSOWithoutAppleDEPProfile) validate that OTA SSO redirects back to /enroll without an error when Apple DEP profiles are absent. A changelog entry and a test stub were added.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately summarizes the main fix: resolving the 'missing profile' error that occurred during MDM SSO callback for Android enrollment scenarios.
Description check ✅ Passed The description is comprehensive and well-structured, covering the bug, fix, refactoring, local reproduction steps, integration test, and test plan. All required sections from the template are addressed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-45024-android-sso-missing-profile

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
server/service/testing_client.go (1)

489-513: 💤 Low value

Optional: extract the shared SAML POST-to-callback flow to reduce duplication.

Steps 3-5 (lines 489–513) are nearly identical to loginSSOUserWithBody lines 592–618 — the same IdP form-submit, SAMLResponse regex extraction, and callback POST. The only structural difference between the two helpers is how the IdP URL is obtained (via /enroll redirect here vs. Fleet /api/v1/fleet/mdm/sso initiation in loginSSOUserWithBody).

Extracting a private helper that accepts an already-known IdP URL and drives the SAML credential submission + callback POST would eliminate the duplication:

♻️ Suggested refactor sketch
+// doSAMLFlow drives the IdP credential submission and Fleet SSO callback given
+// an already-resolved IdP URL. The cookie jar on client must carry any session
+// cookie set before the call. Returns the callback HTTP response.
+func (ts *withServer) doSAMLFlow(client *http.Client, idpURL, callbackPath, username, password string) *http.Response {
+    t := ts.s.T()
+    resp, err := client.Get(idpURL)
+    require.NoError(t, err)
+    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)
+    defer resp.Body.Close()
+    body, err := io.ReadAll(resp.Body)
+    require.NoError(t, err)
+    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)
+    q := url.QueryEscape(string(matches[1]))
+    cbResp, err := client.Post(ts.server.URL+callbackPath+"?SAMLResponse="+q, "application/x-www-form-urlencoded", nil)
+    require.NoError(t, err)
+    return cbResp
+}

Then LoginOTAEnrollSSOUser becomes:

-    // Step 2: Follow IdP redirect to get the login page
-    resp, err = client.Get(idpURL)
-    require.NoError(t, err)
-    // Step 3-5: ...identical block...
+    return ts.doSAMLFlow(client, idpURL, "/api/v1/fleet/mdm/sso/callback", username, password)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/service/testing_client.go` around lines 489 - 513, Steps 3–5 duplicate
logic across LoginOTAEnrollSSOUser and loginSSOUserWithBody; extract that flow
into a private helper (e.g., submitSAMLFromIdP) that takes the IdP URL (string),
an http.Client instance, credentials (username, password), and the callback
base/URL, then performs the form POST to the IdP (reusing client.PostForm),
reads the response body, runs the existing
regexp.MustCompile(`name="SAMLResponse" value="([^\s]*)" />`) to extract the
SAMLResponse, and finally posts the SAMLResponse to the Fleet callback endpoint
(reusing client.Post). Replace the duplicated blocks in LoginOTAEnrollSSOUser
and loginSSOUserWithBody with calls to this helper, preserving error
handling/assertions (require.NoError/require.NotEmptyf) semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@server/service/testing_client.go`:
- Around line 489-513: Steps 3–5 duplicate logic across LoginOTAEnrollSSOUser
and loginSSOUserWithBody; extract that flow into a private helper (e.g.,
submitSAMLFromIdP) that takes the IdP URL (string), an http.Client instance,
credentials (username, password), and the callback base/URL, then performs the
form POST to the IdP (reusing client.PostForm), reads the response body, runs
the existing regexp.MustCompile(`name="SAMLResponse" value="([^\s]*)" />`) to
extract the SAMLResponse, and finally posts the SAMLResponse to the Fleet
callback endpoint (reusing client.Post). Replace the duplicated blocks in
LoginOTAEnrollSSOUser and loginSSOUserWithBody with calls to this helper,
preserving error handling/assertions (require.NoError/require.NotEmptyf)
semantics.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 267bf5b9-2884-4e40-972e-0d14887c9efc

📥 Commits

Reviewing files that changed from the base of the PR and between bc89773 and e856bd3.

📒 Files selected for processing (4)
  • changes/45024-android-sso-missing-profile
  • ee/server/service/mdm.go
  • server/service/integration_mdm_test.go
  • server/service/testing_client.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Fixes an SSO callback failure during OTA enrollment by skipping Apple DEP profile lookup when enrolling via /enroll?, allowing Android/BYOD SSO enrollment to succeed on instances without Apple MDM configured.

Changes:

  • Add an early return in mdmSSOHandleCallbackAuth for OTA enrollment callbacks (/enroll?) to avoid Apple DEP profile requirements.
  • Add an integration regression test covering OTA enrollment SSO without any Apple DEP profile present.
  • Add a testing client helper to execute the full OTA enrollment SSO flow via /enroll?enroll_secret=....

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 6 comments.

File Description
server/service/testing_client.go Adds a helper to drive the OTA enrollment SSO flow end-to-end in integration tests.
server/service/integration_mdm_test.go Adds regression test ensuring OTA enrollment SSO succeeds without Apple DEP profile.
ee/server/service/mdm.go Skips Apple DEP profile access for /enroll? OTA SSO callbacks.
changes/45024-android-sso-missing-profile Adds changelog entry for the fixed Android SSO “missing profile” error.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread server/service/testing_client.go
Comment thread server/service/testing_client.go
Comment thread server/service/testing_client.go
Comment thread server/service/testing_client.go Outdated
Comment thread server/service/testing_client.go
Comment thread server/service/integration_mdm_test.go
Replace raw string literals "ota_enroll", "setup_experience", and
"account_driven_enroll" with named constants in fleet.SSO* to prevent
typos and missed cases. Constants are defined in server/fleet/app.go
so they can be imported by all packages including orbit.
@codecov

codecov Bot commented May 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.82759% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.81%. Comparing base (bc89773) to head (c3faf7f).
⚠️ Report is 79 commits behind head on main.

Files with missing lines Patch % Lines
server/service/testing_client.go 95.91% 1 Missing and 1 partial ⚠️
ee/server/service/mdm.go 87.50% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #45046      +/-   ##
==========================================
+ Coverage   66.77%   66.81%   +0.04%     
==========================================
  Files        2718     2722       +4     
  Lines      218795   219073     +278     
  Branches    10625    10625              
==========================================
+ Hits       146100   146381     +281     
+ Misses      59531    59526       -5     
- Partials    13164    13166       +2     
Flag Coverage Δ
backend 68.69% <94.82%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/service/scripts_test.go`:
- Around line 1045-1046: The TestBatchScriptCancel test is an empty stub that
provides no coverage; either implement the test logic for batch script
cancellation by exercising the relevant cancellation flow (create a batch script
via the same helpers used elsewhere in tests, trigger cancellation via the
cancel function or API, assert the script's state changes and any errors) in
TestBatchScriptCancel, or if the functionality is out of scope for this PR,
replace the empty body with a deliberate placeholder by calling t.Skip("TODO:
implement batch script cancellation tests") so the intent is explicit, or simply
remove the TestBatchScriptCancel function if no test is required; locate the
TestBatchScriptCancel function in the file and apply one of these three changes.

In `@server/service/testing_client.go`:
- Around line 521-526: The test helper currently closes the returned HTTP
response (the require.NoError(t, resp.Body.Close()) call) before returning resp,
making the caller unable to read the body; remove the line that closes resp.Body
so the function returns resp intact (keep callbackURL, samlResponse, client.Post
and resp as-is) and ensure callers are responsible for closing resp.Body to
avoid leaks and to match the behavior of the neighboring SSO helpers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3fb98dd8-4185-44ff-b3e2-7eb2b095f978

📥 Commits

Reviewing files that changed from the base of the PR and between e6db1ae and 7dd9b8d.

📒 Files selected for processing (3)
  • server/service/integration_mdm_test.go
  • server/service/scripts_test.go
  • server/service/testing_client.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/service/integration_mdm_test.go

Comment thread server/service/scripts_test.go Outdated
Comment thread server/service/testing_client.go
@JordanMontgomery
JordanMontgomery merged commit 0276662 into main May 12, 2026
68 of 69 checks passed
@JordanMontgomery
JordanMontgomery deleted the fix-45024-android-sso-missing-profile branch May 12, 2026 16:42
JordanMontgomery added a commit that referenced this pull request May 12, 2026
… enrollment (#45252)

Cherry pick of #45046 into v4.85

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: Sharon Katz <121527325+sharon-fdm@users.noreply.github.com>
Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MDM SSO callback returns "missing profile" error for Android enrollment

4 participants