**Related issue:** Resolves #47343 # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for providing an identity provider (IdP) UUID in enrollment-token requests via an `idp_uuid` query parameter, affecting both fully-managed and non-fully-managed flows. * Enrollment OTA now carries the IdP UUID into the enrollment flow and token request, with server-rendered pages exposing the selected IdP when applicable. * **Bug Fixes** * For fully-managed enrollments, the IdP/SSO cookie is cleared after successful authentication to avoid stale IdP selection. * **Tests** * Extended coverage for `idp_uuid` precedence over the IdP cookie and for cookie-clearing behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
434 lines
14 KiB
Go
434 lines
14 KiB
Go
package android
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"testing"
|
|
|
|
shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm"
|
|
"github.com/fleetdm/fleet/v4/server"
|
|
"github.com/fleetdm/fleet/v4/server/datastore/mysql/mysqltest"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
|
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/api/androidmanagement/v1"
|
|
)
|
|
|
|
func TestAndroid(t *testing.T) {
|
|
s := SetUpSuite(t, "integrationtest.Android")
|
|
|
|
cases := []struct {
|
|
name string
|
|
fn func(t *testing.T, s *Suite)
|
|
}{
|
|
{"HappyPath", testHappyPath},
|
|
{"CreateEnrollmentToken", testCreateEnrollmentToken},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
defer mysqltest.TruncateTables(t, s.DS)
|
|
c.fn(t, s)
|
|
})
|
|
}
|
|
}
|
|
|
|
func testHappyPath(t *testing.T, s *Suite) {
|
|
signupDetails := expectSignupDetails(t, s)
|
|
var signupURL android.EnterpriseSignupResponse
|
|
s.DoJSON(t, "GET", "/api/v1/fleet/android_enterprise/signup_url", nil, http.StatusOK, &signupURL)
|
|
assert.Equal(t, signupURL.Url, signupDetails.Url)
|
|
}
|
|
|
|
type enrollmentTokenRequest struct {
|
|
EnrollSecret string
|
|
IdpUUID string
|
|
}
|
|
|
|
func testCreateEnrollmentToken(t *testing.T, s *Suite) {
|
|
appCfg := &fleet.AppConfig{
|
|
MDM: fleet.MDM{
|
|
AndroidEnabledAndConfigured: true,
|
|
},
|
|
ServerSettings: fleet.ServerSettings{
|
|
ServerURL: "http://localhost",
|
|
},
|
|
}
|
|
enableAndroidMDM := func() {
|
|
_, err := s.DS.NewAppConfig(t.Context(), appCfg)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
createTeamAndSecret := func(name, secret string, enableEndUserAuth bool) {
|
|
team, err := s.DS.NewTeam(t.Context(), &fleet.Team{
|
|
Name: name,
|
|
Config: fleet.TeamConfig{
|
|
MDM: fleet.TeamMDM{
|
|
MacOSSetup: fleet.MacOSSetup{
|
|
EnableEndUserAuthentication: enableEndUserAuth,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
err = s.DS.ApplyEnrollSecrets(t.Context(), &team.ID, []*fleet.EnrollSecret{
|
|
{
|
|
Secret: secret,
|
|
TeamID: &team.ID,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
setupAndroidEnterprise := func() {
|
|
admin := s.Users["admin1"]
|
|
enterpriseID, err := s.DS.CreateEnterprise(t.Context(), admin.ID)
|
|
require.NoError(t, err)
|
|
|
|
// signupToken is used to authenticate the signup callback URL -- to ensure that the callback came from our Android enterprise signup flow
|
|
signupToken, err := server.GenerateRandomURLSafeText(32)
|
|
require.NoError(t, err)
|
|
|
|
callbackURL := fmt.Sprintf("%s/api/v1/fleet/android_enterprise/connect/%s", appCfg.ServerSettings.ServerURL, signupToken)
|
|
signupDetails := android.SignupDetails{
|
|
Name: "test",
|
|
Url: callbackURL,
|
|
}
|
|
|
|
err = s.DS.UpdateEnterprise(t.Context(), &android.EnterpriseDetails{
|
|
Enterprise: android.Enterprise{
|
|
ID: enterpriseID,
|
|
EnterpriseID: "test",
|
|
},
|
|
SignupName: signupDetails.Name,
|
|
SignupToken: signupToken,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
s.AndroidProxy.EnterprisesEnrollmentTokensCreateFunc = func(ctx context.Context, enterpriseName string, token *androidmanagement.EnrollmentToken) (*androidmanagement.EnrollmentToken, error) {
|
|
// For ease of testing and validating, we base64 the json input as the output value
|
|
|
|
jsonString, err := json.Marshal(token)
|
|
require.NoError(t, err)
|
|
base64Encoded := base64.StdEncoding.EncodeToString(jsonString)
|
|
|
|
return &androidmanagement.EnrollmentToken{
|
|
Value: base64Encoded,
|
|
}, nil
|
|
}
|
|
|
|
t.Run("fails", func(t *testing.T) {
|
|
t.Run("if enroll_secret query param is missing", func(t *testing.T) {
|
|
s.Do(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusBadRequest)
|
|
})
|
|
|
|
t.Run("if android MDM is not configured", func(t *testing.T) {
|
|
s.Do(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusConflict, "enroll_secret", "secret")
|
|
})
|
|
|
|
t.Run("if enroll secret is invalid", func(t *testing.T) {
|
|
enableAndroidMDM()
|
|
s.Do(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusUnauthorized, "enroll_secret", "secret")
|
|
})
|
|
|
|
t.Run("if android enterprise is missing", func(t *testing.T) {
|
|
enableAndroidMDM()
|
|
secret := "global-enterprise-missing"
|
|
createTeamAndSecret(secret, secret, false)
|
|
resp := s.Do(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusNotFound, "enroll_secret", secret)
|
|
je := decodeJsonError(t, resp)
|
|
|
|
require.Contains(t, "Android enterprise", je.Errors[0]["base"])
|
|
mysqltest.TruncateTables(t, s.DS)
|
|
})
|
|
|
|
t.Run("if idp account does not exist", func(t *testing.T) {
|
|
enableAndroidMDM()
|
|
secret := "global-no-idp-account" // nolint: gosec
|
|
createTeamAndSecret(secret, secret, false)
|
|
resp := s.DoRawWithHeaders(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusUnprocessableEntity, map[string]string{
|
|
"Cookie": fmt.Sprintf("%s=%s", shared_mdm.BYODIdpCookieName, "test-uuid"),
|
|
}, "enroll_secret", secret)
|
|
je := decodeJsonError(t, resp)
|
|
|
|
require.Contains(t, "validating idp account existence", je.Errors[0]["base"])
|
|
mysqltest.TruncateTables(t, s.DS)
|
|
})
|
|
|
|
t.Run("if idp is required but not set", func(t *testing.T) {
|
|
enableAndroidMDM()
|
|
secret := "team"
|
|
createTeamAndSecret("team", secret, true)
|
|
s.DoRaw(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusUnauthorized, "enroll_secret", secret)
|
|
})
|
|
|
|
t.Cleanup(func() {
|
|
mysqltest.TruncateTables(t, s.DS)
|
|
})
|
|
})
|
|
|
|
t.Run("succeeds", func(t *testing.T) {
|
|
globalSecret := "global"
|
|
|
|
t.Run("when enroll secret is passed", func(t *testing.T) {
|
|
enableAndroidMDM()
|
|
createTeamAndSecret(globalSecret, globalSecret, false)
|
|
setupAndroidEnterprise()
|
|
|
|
var resp android.EnrollmentTokenResponse
|
|
s.DoJSON(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusOK, &resp, "enroll_secret", globalSecret)
|
|
|
|
decoded, err := base64.StdEncoding.DecodeString(resp.EnrollmentToken.EnrollmentToken)
|
|
require.NoError(t, err)
|
|
var et androidmanagement.EnrollmentToken
|
|
err = json.Unmarshal(decoded, &et)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "PERSONAL_USAGE_ALLOWED", et.AllowPersonalUsage)
|
|
|
|
var enrollmentRequest enrollmentTokenRequest
|
|
err = json.Unmarshal([]byte(et.AdditionalData), &enrollmentRequest)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, globalSecret, enrollmentRequest.EnrollSecret)
|
|
require.Equal(t, "", enrollmentRequest.IdpUUID)
|
|
|
|
t.Cleanup(func() {
|
|
mysqltest.TruncateTables(t, s.DS)
|
|
})
|
|
})
|
|
|
|
t.Run("when enroll and idp uuid is set", func(t *testing.T) {
|
|
enableAndroidMDM()
|
|
createTeamAndSecret(globalSecret, globalSecret, true)
|
|
setupAndroidEnterprise()
|
|
idpEmail := "test@local.com"
|
|
err := s.DS.InsertMDMIdPAccount(t.Context(), &fleet.MDMIdPAccount{
|
|
Username: "test",
|
|
Email: idpEmail,
|
|
})
|
|
require.NoError(t, err)
|
|
idpAccount, err := s.DS.GetMDMIdPAccountByEmail(t.Context(), idpEmail)
|
|
require.NoError(t, err)
|
|
|
|
resp := s.DoRawWithHeaders(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token", nil, http.StatusOK, map[string]string{
|
|
"Cookie": fmt.Sprintf("%s=%s", shared_mdm.BYODIdpCookieName, idpAccount.UUID),
|
|
}, "enroll_secret", globalSecret)
|
|
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
var etr android.EnrollmentTokenResponse
|
|
err = json.Unmarshal(bodyBytes, &etr)
|
|
require.NoError(t, err)
|
|
|
|
decoded, err := base64.StdEncoding.DecodeString(etr.EnrollmentToken.EnrollmentToken)
|
|
require.NoError(t, err)
|
|
var et androidmanagement.EnrollmentToken
|
|
err = json.Unmarshal(decoded, &et)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "PERSONAL_USAGE_ALLOWED", et.AllowPersonalUsage)
|
|
|
|
var enrollmentRequest enrollmentTokenRequest
|
|
err = json.Unmarshal([]byte(et.AdditionalData), &enrollmentRequest)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, globalSecret, enrollmentRequest.EnrollSecret)
|
|
require.Equal(t, idpAccount.UUID, enrollmentRequest.IdpUUID)
|
|
|
|
t.Cleanup(func() {
|
|
mysqltest.TruncateTables(t, s.DS)
|
|
})
|
|
})
|
|
|
|
t.Run("when idp_uuid is passed as query param", func(t *testing.T) {
|
|
enableAndroidMDM()
|
|
createTeamAndSecret(globalSecret, globalSecret, true)
|
|
setupAndroidEnterprise()
|
|
idpEmail := "queryparam@local.com"
|
|
err := s.DS.InsertMDMIdPAccount(t.Context(), &fleet.MDMIdPAccount{
|
|
Username: "queryparam",
|
|
Email: idpEmail,
|
|
})
|
|
require.NoError(t, err)
|
|
idpAccount, err := s.DS.GetMDMIdPAccountByEmail(t.Context(), idpEmail)
|
|
require.NoError(t, err)
|
|
|
|
// Pass idp_uuid as query parameter (no cookie). This is the path
|
|
// used after the BYOD cookie is cleared for fully-managed Android.
|
|
resp := s.DoRawWithHeaders(t, "GET",
|
|
fmt.Sprintf("/api/v1/fleet/android_enterprise/enrollment_token?enroll_secret=%s&fully_managed=true&idp_uuid=%s", globalSecret, idpAccount.UUID),
|
|
nil, http.StatusOK, nil,
|
|
)
|
|
defer resp.Body.Close()
|
|
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
var etr android.EnrollmentTokenResponse
|
|
err = json.Unmarshal(bodyBytes, &etr)
|
|
require.NoError(t, err)
|
|
|
|
decoded, err := base64.StdEncoding.DecodeString(etr.EnrollmentToken.EnrollmentToken)
|
|
require.NoError(t, err)
|
|
var et androidmanagement.EnrollmentToken
|
|
err = json.Unmarshal(decoded, &et)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "PERSONAL_USAGE_DISALLOWED", et.AllowPersonalUsage)
|
|
|
|
var enrollmentRequest enrollmentTokenRequest
|
|
err = json.Unmarshal([]byte(et.AdditionalData), &enrollmentRequest)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, globalSecret, enrollmentRequest.EnrollSecret)
|
|
require.Equal(t, idpAccount.UUID, enrollmentRequest.IdpUUID)
|
|
|
|
t.Cleanup(func() {
|
|
mysqltest.TruncateTables(t, s.DS)
|
|
})
|
|
})
|
|
|
|
t.Run("when idp_uuid query param takes precedence over cookie", func(t *testing.T) {
|
|
enableAndroidMDM()
|
|
createTeamAndSecret(globalSecret, globalSecret, true)
|
|
setupAndroidEnterprise()
|
|
|
|
// Create two IdP accounts
|
|
err := s.DS.InsertMDMIdPAccount(t.Context(), &fleet.MDMIdPAccount{
|
|
Username: "cookie-user",
|
|
Email: "cookie@local.com",
|
|
})
|
|
require.NoError(t, err)
|
|
cookieAccount, err := s.DS.GetMDMIdPAccountByEmail(t.Context(), "cookie@local.com")
|
|
require.NoError(t, err)
|
|
|
|
err = s.DS.InsertMDMIdPAccount(t.Context(), &fleet.MDMIdPAccount{
|
|
Username: "param-user",
|
|
Email: "param@local.com",
|
|
})
|
|
require.NoError(t, err)
|
|
paramAccount, err := s.DS.GetMDMIdPAccountByEmail(t.Context(), "param@local.com")
|
|
require.NoError(t, err)
|
|
|
|
// Send both cookie and query param with different UUIDs
|
|
resp := s.DoRawWithHeaders(t, "GET",
|
|
fmt.Sprintf("/api/v1/fleet/android_enterprise/enrollment_token?enroll_secret=%s&fully_managed=true&idp_uuid=%s", globalSecret, paramAccount.UUID),
|
|
nil, http.StatusOK, map[string]string{
|
|
"Cookie": fmt.Sprintf("%s=%s", shared_mdm.BYODIdpCookieName, cookieAccount.UUID),
|
|
},
|
|
)
|
|
defer resp.Body.Close()
|
|
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
var etr android.EnrollmentTokenResponse
|
|
err = json.Unmarshal(bodyBytes, &etr)
|
|
require.NoError(t, err)
|
|
|
|
decoded, err := base64.StdEncoding.DecodeString(etr.EnrollmentToken.EnrollmentToken)
|
|
require.NoError(t, err)
|
|
var et androidmanagement.EnrollmentToken
|
|
err = json.Unmarshal(decoded, &et)
|
|
require.NoError(t, err)
|
|
|
|
var enrollmentRequest enrollmentTokenRequest
|
|
err = json.Unmarshal([]byte(et.AdditionalData), &enrollmentRequest)
|
|
require.NoError(t, err)
|
|
|
|
// Query param UUID should win over cookie UUID
|
|
require.Equal(t, paramAccount.UUID, enrollmentRequest.IdpUUID)
|
|
|
|
t.Cleanup(func() {
|
|
mysqltest.TruncateTables(t, s.DS)
|
|
})
|
|
})
|
|
|
|
t.Run("when fully_managed is true", func(t *testing.T) {
|
|
enableAndroidMDM()
|
|
createTeamAndSecret(globalSecret, globalSecret, false)
|
|
setupAndroidEnterprise()
|
|
|
|
var resp android.EnrollmentTokenResponse
|
|
s.DoJSON(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token?fully_managed=true", nil, http.StatusOK, &resp, "enroll_secret", globalSecret)
|
|
|
|
decoded, err := base64.StdEncoding.DecodeString(resp.EnrollmentToken.EnrollmentToken)
|
|
require.NoError(t, err)
|
|
var et androidmanagement.EnrollmentToken
|
|
err = json.Unmarshal(decoded, &et)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "PERSONAL_USAGE_DISALLOWED", et.AllowPersonalUsage)
|
|
|
|
var enrollmentRequest enrollmentTokenRequest
|
|
err = json.Unmarshal([]byte(et.AdditionalData), &enrollmentRequest)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, globalSecret, enrollmentRequest.EnrollSecret)
|
|
require.Equal(t, "", enrollmentRequest.IdpUUID)
|
|
|
|
t.Cleanup(func() {
|
|
mysqltest.TruncateTables(t, s.DS)
|
|
})
|
|
})
|
|
|
|
t.Run("when fully_managed is false", func(t *testing.T) {
|
|
enableAndroidMDM()
|
|
createTeamAndSecret(globalSecret, globalSecret, false)
|
|
setupAndroidEnterprise()
|
|
|
|
var resp android.EnrollmentTokenResponse
|
|
s.DoJSON(t, "GET", "/api/v1/fleet/android_enterprise/enrollment_token?fully_managed=false", nil, http.StatusOK, &resp, "enroll_secret", globalSecret)
|
|
|
|
decoded, err := base64.StdEncoding.DecodeString(resp.EnrollmentToken.EnrollmentToken)
|
|
require.NoError(t, err)
|
|
var et androidmanagement.EnrollmentToken
|
|
err = json.Unmarshal(decoded, &et)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "PERSONAL_USAGE_ALLOWED", et.AllowPersonalUsage)
|
|
|
|
var enrollmentRequest enrollmentTokenRequest
|
|
err = json.Unmarshal([]byte(et.AdditionalData), &enrollmentRequest)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, globalSecret, enrollmentRequest.EnrollSecret)
|
|
require.Equal(t, "", enrollmentRequest.IdpUUID)
|
|
|
|
t.Cleanup(func() {
|
|
mysqltest.TruncateTables(t, s.DS)
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
func expectSignupDetails(t *testing.T, s *Suite) *android.SignupDetails {
|
|
signupDetails := &android.SignupDetails{
|
|
Url: "URL",
|
|
Name: "Name",
|
|
}
|
|
s.AndroidProxy.SignupURLsCreateFunc = func(_ context.Context, serverURL, callbackURL string) (*android.SignupDetails, error) {
|
|
assert.Equal(t, s.Server.URL, serverURL)
|
|
// We will need to extract the security token from the callbackURL for further testing
|
|
assert.Contains(t, callbackURL, "/api/v1/fleet/android_enterprise/connect/")
|
|
return signupDetails, nil
|
|
}
|
|
return signupDetails
|
|
}
|
|
|
|
func decodeJsonError(t *testing.T, response *http.Response) endpointer.JsonError {
|
|
bodyBytes, err := io.ReadAll(response.Body)
|
|
require.NoError(t, err)
|
|
|
|
var je endpointer.JsonError
|
|
err = json.Unmarshal(bodyBytes, &je)
|
|
require.NoError(t, err)
|
|
|
|
return je
|
|
}
|