diff --git a/changes/30871-default-byod-fleet b/changes/30871-default-byod-fleet new file mode 100644 index 00000000000..3b3140705b8 --- /dev/null +++ b/changes/30871-default-byod-fleet @@ -0,0 +1 @@ +- Added support for defining the default fleet BYO Apple devices enroll into. \ No newline at end of file diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go index b00891a0a74..f941a35d0ca 100644 --- a/cmd/fleet/cron.go +++ b/cmd/fleet/cron.go @@ -2486,3 +2486,28 @@ func newManagedLocalAccountRotationSchedule( return s, nil } + +func newCleanupExpiredADUEChallengesSchedule( + ctx context.Context, + instanceID string, + ds fleet.Datastore, + logger *slog.Logger, +) (*schedule.Schedule, error) { + const ( + name = string(fleet.CronCleanupExpiredADUEChallenges) + defaultInterval = 24 * time.Hour + ) + logger = logger.With("cron", name) + s := schedule.New( + ctx, name, instanceID, defaultInterval, ds, ds, + schedule.WithLogger(logger), + schedule.WithJob("cleanup_expired_adue_challenges", func(ctx context.Context) error { + if err := ds.CleanupExpiredADUEEnrollmentChallenges(ctx); err != nil { + return ctxerr.Wrap(ctx, err, "cleaning up expired ADUE challenges") + } + return nil + }), + ) + + return s, nil +} diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go index 1552a8166d3..5af9d589fcc 100644 --- a/cmd/fleet/serve.go +++ b/cmd/fleet/serve.go @@ -917,6 +917,12 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev }); err != nil { initFatal(err, "failed to register managed local account rotation schedule") } + + if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) { + return newCleanupExpiredADUEChallengesSchedule(ctx, instanceID, ds, logger) + }); err != nil { + initFatal(err, "failed to register cleanup expired ADUE challenges schedule") + } } if license.IsPremium() && config.Activity.EnableAuditLog { diff --git a/ee/server/service/apple_mdm.go b/ee/server/service/apple_mdm.go new file mode 100644 index 00000000000..c80800fea05 --- /dev/null +++ b/ee/server/service/apple_mdm.go @@ -0,0 +1,82 @@ +package service + +import ( + "context" + "fmt" + + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/fleet" + apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + mdmcrypto "github.com/fleetdm/fleet/v4/server/mdm/crypto" +) + +func (svc *Service) GetMDMAccountDrivenEnrollmentSSOURL(ctx context.Context, enrollmentToken string) (string, error) { + // skipauth: The enroll profile endpoint is unauthenticated. + svc.authz.SkipAuthorization(ctx) + + appConfig, err := svc.ds.AppConfig(ctx) + if err != nil { + return "", ctxerr.Wrap(ctx, err) + } + url := appConfig.MDMUrl() + "/mdm/apple/account_driven_enroll/sso" + + if enrollmentToken != "" { + url = fmt.Sprintf("%s/%s", url, enrollmentToken) + } + + return url, nil +} + +func (svc *Service) GetMDMAppleAccountEnrollmentProfile(ctx context.Context, enrollRef string) (profile []byte, err error) { + // skipauth: This enrollment endpoint is authenticated only by the enrollment reference. + svc.authz.SkipAuthorization(ctx) + + enrollChallenge, err := svc.ds.ConsumeADUEEnrollmentChallenge(ctx, enrollRef) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "consuming account driven enrollment challenge") + } + if enrollChallenge == nil { + return nil, &fleet.BadRequestError{Message: "account driven enrollment challenge not found"} + } + + idpAccount, err := svc.ds.GetMDMIdPAccountByUUID(ctx, enrollChallenge.IdPAccountUUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "getting MDM IdP account by UUID") + } + + appConfig, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, ctxerr.Wrap(ctx, err) + } + + topic, err := apple_mdm.MDMPushCertTopic(ctx, svc.ds) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert") + } + + assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ + fleet.MDMAssetSCEPChallenge, + }, nil) + if err != nil { + return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err) + } + enrollURL := appConfig.MDMUrl() + + enrollmentProf, err := apple_mdm.GenerateAccountDrivenEnrollmentProfileMobileconfig( + appConfig.OrgInfo.OrgName, + enrollURL, + string(assets[fleet.MDMAssetSCEPChallenge].Value), + topic, + idpAccount.Email, + ) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "generating enrollment profile") + } + + signed, err := mdmcrypto.Sign(ctx, enrollmentProf, svc.ds) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "signing profile") + } + + return signed, nil +} diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 4c72b5ef730..0730f5c28a8 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -841,11 +841,9 @@ func (svc *Service) DeleteMDMAppleSetupAssistant(ctx context.Context, teamID *ui return nil } -const appleMDMAccountDrivenEnrollmentUrl = "/api/mdm/apple/account_driven_enroll" - func (svc *Service) InitiateMDMSSO(ctx context.Context, initiator, customOriginalURL string, hostUUID string) (sessionID string, sessionDurationSeconds int, idpURL string, err error) { // skipauth: User context does not yet exist. Unauthenticated users may - // initiate SSO. + // initiate MDM SSO. svc.authz.SkipAuthorization(ctx) logging.WithLevel(logging.WithNoUser(ctx), slog.LevelInfo) @@ -885,13 +883,24 @@ func (svc *Service) InitiateMDMSSO(ctx context.Context, initiator, customOrigina } originalURL := "/" - switch initiator { - case fleet.SSOInitiatorAccountDrivenEnroll: + switch { + case strings.HasPrefix(initiator, fleet.SSOInitiatorAccountDrivenEnroll): + var token string + + if uniqueToken, ok := strings.CutPrefix(initiator, fleet.SSOInitiatorAccountDrivenEnroll+":"); ok { + token = uniqueToken + } // 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 fleet.SSOInitiatorOTAEnroll: + originalURL = apple_mdm.AccountDrivenEnrollPath // nolint:staticcheck // This is kept for backwards compatibility + + if token != "" { + // We need this check for backwards compatibility. + tokenURL := apple_mdm.AccountDrivenEnrollTokenPath + originalURL = strings.Replace(tokenURL, "{token}", token, 1) + } + case initiator == 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). @@ -916,7 +925,7 @@ func (svc *Service) InitiateMDMSSO(ctx context.Context, initiator, customOrigina func (svc *Service) MDMSSOCallback(ctx context.Context, sessionID string, samlResponse []byte) (redirectURL, byodCookieValue string) { // skipauth: User context does not yet exist. Unauthenticated users may - // hit the SSO callback. + // hit the MDM SSO callback. svc.authz.SkipAuthorization(ctx) logging.WithLevel(logging.WithNoUser(ctx), slog.LevelInfo) @@ -952,10 +961,28 @@ func (svc *Service) MDMSSOCallback(ctx context.Context, sessionID string, samlRe q.Add("initiator", ssoRequestData.Initiator) switch { - case originalURL == appleMDMAccountDrivenEnrollmentUrl: + case strings.HasPrefix(ssoRequestData.Initiator, fleet.SSOInitiatorAccountDrivenEnroll): + var abmTokenID *uint + + if uniqueToken, ok := strings.CutPrefix(ssoRequestData.Initiator, fleet.SSOInitiatorAccountDrivenEnroll+":"); ok && uniqueToken != "" { + // Extract the unique token to retrieve the ABM token row id. + token, err := svc.ds.GetABMTokenByUniqueToken(ctx, uniqueToken) + if err != nil { + logging.WithErr(ctx, ctxerr.Wrap(ctx, err, "get ABM token by unique token for account driven enrollment")) + return apple_mdm.FleetUISSOCallbackPath + "?error=true", "" + } + abmTokenID = &token.ID + } + + challenge, err := svc.ds.InsertADUEEnrollmentChallenge(ctx, abmTokenID, enrollmentRef, fleet.ADUEEnrollmentChallengeExpiration) + if err != nil { + logging.WithErr(ctx, ctxerr.Wrap(ctx, err, "insert ADUE enrollment challenge for account driven enrollment")) + return apple_mdm.FleetUISSOCallbackPath + "?error=true", "" + } + // For account driven enrollment we have to use this special protocol URL scheme to pass the // access token back to Apple which it will then use to request the enrollment profile. - return fmt.Sprintf("apple-remotemanagement-user-login://authentication-results?access-token=%s", enrollmentRef), "" + return fmt.Sprintf("apple-remotemanagement-user-login://authentication-results?access-token=%s", challenge), "" case strings.HasPrefix(originalURL, "/enroll?"): // redirect to the original URL with a cookie that identifies this device diff --git a/frontend/pages/MDMAppleSSOPage/MDMAppleSSOPage.tsx b/frontend/pages/MDMAppleSSOPage/MDMAppleSSOPage.tsx index 2d38588772b..c02fd353ca8 100644 --- a/frontend/pages/MDMAppleSSOPage/MDMAppleSSOPage.tsx +++ b/frontend/pages/MDMAppleSSOPage/MDMAppleSSOPage.tsx @@ -11,19 +11,28 @@ import Button from "components/buttons/Button"; import CustomLink from "components/CustomLink"; import { IMdmSSOResponse } from "interfaces/mdm"; import AuthenticationFormWrapper from "components/AuthenticationFormWrapper"; +import { Params } from "react-router/lib/Router"; const baseClass = "mdm-apple-sso-page"; const DEPSSOLoginPage = ({ location: { pathname, query }, -}: WithRouterProps) => { + params, +}: WithRouterProps) => { const [clickedLogin, setClickedLogin] = useState(false); localStorage.setItem("deviceinfo", query.deviceinfo || ""); if (!query.initiator) { - query.initiator = - pathname === "/mdm/apple/account_driven_enroll/sso" - ? "account_driven_enroll" - : "mdm_sso"; + if (pathname.startsWith("/mdm/apple/account_driven_enroll/sso")) { + // While I acknowledge startsWith for route matching is a bit brittle + // I couldn't find a better way, since the pathname is the actual resolved value and not the placeholder route. + if (params.token) { + query.initiator = `account_driven_enroll:${params.token}`; + } else { + query.initiator = "account_driven_enroll"; + } + } else { + query.initiator = "mdm_sso"; + } } const { error } = useQuery( ["dep_sso"], diff --git a/frontend/router/index.tsx b/frontend/router/index.tsx index 2968e95b6b9..7128d39e5dd 100644 --- a/frontend/router/index.tsx +++ b/frontend/router/index.tsx @@ -165,6 +165,10 @@ const routes = ( path="mdm/apple/account_driven_enroll/sso" component={MDMAppleSSOPage} /> + diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go index 3d43545d4c8..8b031965592 100644 --- a/pkg/mdm/mdmtest/apple.go +++ b/pkg/mdm/mdmtest/apple.go @@ -359,8 +359,11 @@ func (c *TestAppleMDMClient) enrollDevice(awaitingConfiguration bool) error { return fmt.Errorf("get enrollment profile from OTA URL: %w", err) } case c.fetchEnrollmentProfileFromMDMBYOD: - if err := c.fetchEnrollmentProfileFromMDMBYODURL(); err != nil { - return fmt.Errorf("get enrollment profile from MDM BYOD URL: %w", err) + if awaitingConfiguration { + // awaitingConfiguration=true only comes from new enrollments, and for re-enrollments we don't want to refetch the profile. + if err := c.fetchEnrollmentProfileFromMDMBYODURL(); err != nil { + return fmt.Errorf("get enrollment profile from MDM BYOD URL: %w", err) + } } default: if c.EnrollInfo.SCEPURL == "" || c.EnrollInfo.MDMURL == "" || c.EnrollInfo.SCEPChallenge == "" { diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 3d7f79594b6..35816b80464 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -1414,8 +1414,31 @@ func insertMDMAppleHostDB( fromPersonalEnrollment bool, ) error { refetchRequested, lastEnrolledAt := mdmHostEnrollFields(mdmHost) - insertStmt := ` + + var args []any + extraColumns := "" + if mdmHost.TeamID != nil { + extraColumns += "team_id," + args = append(args, *mdmHost.TeamID) + } + + args = append(args, mdmHost.HardwareSerial, + mdmHost.UUID, + mdmHost.HardwareModel, + mdmHost.Platform, + lastEnrolledAt, + server.NeverTimestamp, + mdmHost.UUID, + refetchRequested) + + var placeholders []string + for range args { + placeholders = append(placeholders, "?") + } + + insertStmt := fmt.Sprintf(` INSERT INTO hosts ( + %s hardware_serial, uuid, hardware_model, @@ -1424,19 +1447,12 @@ func insertMDMAppleHostDB( detail_updated_at, osquery_host_id, refetch_requested - ) VALUES (?,?,?,?,?,?,?,?)` + ) VALUES (%s)`, extraColumns, strings.Join(placeholders, ",")) res, err := tx.ExecContext( ctx, insertStmt, - mdmHost.HardwareSerial, - mdmHost.UUID, - mdmHost.HardwareModel, - mdmHost.Platform, - lastEnrolledAt, - server.NeverTimestamp, - mdmHost.UUID, - refetchRequested, + args..., ) if err != nil { return ctxerr.Wrap(ctx, err, "insert mdm apple host") @@ -5710,7 +5726,7 @@ LIMIT 500 } func (ds *Datastore) GetABMTokenByOrgName(ctx context.Context, orgName string) (*fleet.ABMToken, error) { - tok, err := ds.getABMToken(ctx, 0, orgName) + tok, err := ds.getABMToken(ctx, 0, orgName, "") if err != nil { return nil, ctxerr.Wrap(ctx, err, "get ABM token by org name") } @@ -5718,6 +5734,15 @@ func (ds *Datastore) GetABMTokenByOrgName(ctx context.Context, orgName string) ( return tok, nil } +func (ds *Datastore) GetABMTokenByUniqueToken(ctx context.Context, uniqueToken string) (*fleet.ABMToken, error) { + tok, err := ds.getABMToken(ctx, 0, "", uniqueToken) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get ABM token by unique token") + } + + return tok, nil +} + func (ds *Datastore) SaveABMToken(ctx context.Context, tok *fleet.ABMToken) error { const stmt = ` UPDATE @@ -5821,6 +5846,7 @@ SELECT abt.terms_expired, abt.renew_at, abt.token, + abt.enrollment_url_token, abt.macos_default_team_id, abt.ios_default_team_id, abt.ipados_default_team_id, @@ -5904,7 +5930,7 @@ WHERE ID = ? } func (ds *Datastore) GetABMTokenByID(ctx context.Context, tokenID uint) (*fleet.ABMToken, error) { - tok, err := ds.getABMToken(ctx, tokenID, "") + tok, err := ds.getABMToken(ctx, tokenID, "", "") if err != nil { return nil, ctxerr.Wrap(ctx, err, "get ABM token by id") } @@ -5912,7 +5938,7 @@ func (ds *Datastore) GetABMTokenByID(ctx context.Context, tokenID uint) (*fleet. return tok, nil } -func (ds *Datastore) getABMToken(ctx context.Context, tokenID uint, orgName string) (*fleet.ABMToken, error) { +func (ds *Datastore) getABMToken(ctx context.Context, tokenID uint, orgName string, uniqueToken string) (*fleet.ABMToken, error) { stmt := ` SELECT abt.id, @@ -5921,9 +5947,11 @@ SELECT abt.terms_expired, abt.renew_at, abt.token, + abt.enrollment_url_token, abt.macos_default_team_id, abt.ios_default_team_id, abt.ipados_default_team_id, + abt.byod_default_team_id, COALESCE(t1.name, :no_team) as macos_team, COALESCE(t2.name, :no_team) as ios_team, COALESCE(t3.name, :no_team) as ipados_team @@ -5948,6 +5976,9 @@ LEFT OUTER JOIN if tokenID != 0 { clause = "WHERE abt.id = ?" ident = tokenID + } else if uniqueToken != "" { + clause = "WHERE abt.enrollment_url_token = ?" + ident = uniqueToken } stmt = fmt.Sprintf(stmt, clause) @@ -7528,3 +7559,97 @@ func trackAppleUpdateConfigProfileDB(ctx context.Context, tx sqlx.ExtContext, te } return nil } + +func (ds *Datastore) InsertADUEEnrollmentChallenge(ctx context.Context, abmTokenID *uint, idpAccountUUID string, expiration time.Duration) (challenge string, err error) { + if expiration.Seconds() <= 0 { + return "", ctxerr.New(ctx, "challenge expiration must be greater than zero") + } + + if idpAccountUUID == "" { + return "", ctxerr.New(ctx, "idp account uuid is required") + } + + challengeBytes, err := fleet.GenerateRandom32ByteEntropyURLSafeToken() + if err != nil { + return "", ctxerr.Wrap(ctx, err, "generating ADUE enrollment challenge") + } + challenge = string(challengeBytes) + + expireAt := time.Now().Add(expiration) + const stmt = `INSERT INTO mdm_adue_enrollment_challenges (challenge, abm_token_id, idp_account_uuid, expires_at) VALUES (?, ?, ?, ?)` + if _, err := ds.writer(ctx).ExecContext(ctx, stmt, challenge, abmTokenID, idpAccountUUID, expireAt); err != nil { + return "", ctxerr.Wrap(ctx, err, "inserting ADUE enrollment challenge") + } + + return challenge, nil +} + +func (ds *Datastore) GetADUEEnrollmentChallenge(ctx context.Context, challenge string) (*fleet.ADUEEnrollmentChallenge, error) { + const stmt = `SELECT id, abm_token_id, idp_account_uuid, expires_at, used_at FROM mdm_adue_enrollment_challenges WHERE challenge = ?` + var adueChallenge fleet.ADUEEnrollmentChallenge + if err := sqlx.GetContext(ctx, ds.reader(ctx), &adueChallenge, stmt, challenge); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("ADUEEnrollmentChallenge")) + } + return nil, ctxerr.Wrap(ctx, err, "getting ADUE enrollment challenge with lock") + } + return &adueChallenge, nil +} + +func (ds *Datastore) getADUEEnrollmentChallengeForWrite(ctx context.Context, tx sqlx.QueryerContext, challenge string) (*fleet.ADUEEnrollmentChallenge, error) { + const stmt = `SELECT id, abm_token_id, idp_account_uuid, expires_at, used_at FROM mdm_adue_enrollment_challenges WHERE challenge = ? FOR UPDATE` + var adueChallenge fleet.ADUEEnrollmentChallenge + if err := sqlx.GetContext(ctx, tx, &adueChallenge, stmt, challenge); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ctxerr.Wrap(ctx, notFound("ADUEEnrollmentChallenge")) + } + return nil, ctxerr.Wrap(ctx, err, "getting ADUE enrollment challenge with lock") + } + return &adueChallenge, nil +} + +func (ds *Datastore) ConsumeADUEEnrollmentChallenge(ctx context.Context, challenge string) (*fleet.ADUEEnrollmentChallenge, error) { + var enrollChallenge *fleet.ADUEEnrollmentChallenge + err := ds.withTx(ctx, func(tx sqlx.ExtContext) error { + chall, err := ds.getADUEEnrollmentChallengeForWrite(ctx, tx, challenge) + if err != nil { + return ctxerr.Wrap(ctx, err, "fetching account driven enrollment challenge") + } + + if chall == nil { + // It should not be nil, as that would have produced an error, but better safe than sorry. + return ctxerr.New(ctx, "account driven enrollment challenge not found") + } + + enrollChallenge = chall + + if enrollChallenge.UsedAt != nil { + return &fleet.BadRequestError{ + Message: "account driven enrollment challenge can only be used once", + } + } + + if time.Now().After(enrollChallenge.ExpiresAt) { + return &fleet.BadRequestError{ + Message: "account driven enrollment challenge has expired", + } + } + const stmt = `UPDATE mdm_adue_enrollment_challenges SET used_at = NOW() WHERE id = ? AND used_at IS NULL` + if _, err := tx.ExecContext(ctx, stmt, enrollChallenge.ID); err != nil { + return ctxerr.Wrap(ctx, err, "consuming ADUE enrollment challenge") + } + return nil + }) + if err != nil { + return nil, err + } + return enrollChallenge, nil +} + +func (ds *Datastore) CleanupExpiredADUEEnrollmentChallenges(ctx context.Context) error { + const stmt = `DELETE FROM mdm_adue_enrollment_challenges WHERE expires_at < NOW() - INTERVAL 24 HOUR` + if _, err := ds.writer(ctx).ExecContext(ctx, stmt); err != nil { + return ctxerr.Wrap(ctx, err, "cleaning up expired ADUE enrollment challenges") + } + return nil +} diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index d9503bd6403..56d3a39c88d 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -52,6 +52,10 @@ func TestMDMApple(t *testing.T) { name string fn func(t *testing.T, ds *Datastore) }{ + {"GetABMTokenByUniqueToken", testGetABMTokenByUniqueToken}, + {"InsertADUEEnrollmentChallenge", testInsertADUEEnrollmentChallenge}, + {"ConsumeADUEEnrollmentChallenge", testConsumeADUEEnrollmentChallenge}, + {"CleanupExpiredADUEEnrollmentChallenges", testCleanupExpiredADUEEnrollmentChallenges}, {"TestNewMDMAppleConfigProfileDuplicateName", testNewMDMAppleConfigProfileDuplicateName}, {"TestNewMDMAppleConfigProfileLabels", testNewMDMAppleConfigProfileLabels}, {"TestNewMDMAppleConfigProfileDuplicateIdentifier", testNewMDMAppleConfigProfileDuplicateIdentifier}, @@ -12806,3 +12810,250 @@ func testRecoveryLockReadersReturnNotFoundForSoftDeleted(t *testing.T, ds *Datas require.NoError(t, err) assert.Nil(t, got) } + +func testGetABMTokenByUniqueToken(t *testing.T, ds *Datastore) { + ctx := t.Context() + + // Insert an ABM token. + encTok := uuid.NewString() + inserted, err := ds.InsertABMToken(ctx, &fleet.ABMToken{ + OrganizationName: "unique-token-org", + EncryptedToken: []byte(encTok), + RenewAt: time.Now().Add(365 * 24 * time.Hour), + }) + require.NoError(t, err) + require.NotZero(t, inserted.ID) + + // Fetch the enrollment_url_token that was generated during insert. + var urlToken string + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &urlToken, `SELECT enrollment_url_token FROM abm_tokens WHERE id = ?`, inserted.ID) + }) + require.NotEmpty(t, urlToken) + + // Fetch by the unique token – should succeed and return the correct token. + tok, err := ds.GetABMTokenByUniqueToken(ctx, urlToken) + require.NoError(t, err) + require.NotNil(t, tok) + require.Equal(t, inserted.ID, tok.ID) + require.Equal(t, "unique-token-org", tok.OrganizationName) + + // Fetch with a non-existent unique token – should return a not-found error. + _, err = ds.GetABMTokenByUniqueToken(ctx, "no-such-token") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) +} + +func testInsertADUEEnrollmentChallenge(t *testing.T, ds *Datastore) { + ctx := t.Context() + + abmTok, err := ds.InsertABMToken(ctx, &fleet.ABMToken{ + OrganizationName: "adue-org", + EncryptedToken: []byte(uuid.NewString()), + RenewAt: time.Now().Add(365 * 24 * time.Hour), + }) + require.NoError(t, err) + + idpUUID := uuid.NewString() + err = ds.InsertMDMIdPAccount(ctx, &fleet.MDMIdPAccount{ + UUID: idpUUID, + Username: "adue-user", + Fullname: "ADUE User", + Email: "adue-user@example.com", + }) + require.NoError(t, err) + + challenge, err := ds.InsertADUEEnrollmentChallenge(ctx, &abmTok.ID, idpUUID, time.Hour) + require.NoError(t, err) + require.NotEmpty(t, challenge) + + var row struct { + Challenge string `db:"challenge"` + ABMTokenID *uint `db:"abm_token_id"` + IDPAccountID string `db:"idp_account_uuid"` + ExpiresAt time.Time `db:"expires_at"` + } + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &row, ` + SELECT challenge, abm_token_id, idp_account_uuid, expires_at + FROM mdm_adue_enrollment_challenges + WHERE challenge = ? + `, challenge) + }) + require.Equal(t, challenge, row.Challenge) + require.Equal(t, &abmTok.ID, row.ABMTokenID) + require.Equal(t, idpUUID, row.IDPAccountID) + require.True(t, row.ExpiresAt.After(time.Now())) + + // Fails with empty expiration duration + _, err = ds.InsertADUEEnrollmentChallenge(ctx, &abmTok.ID, idpUUID, 0) + require.Error(t, err) + + // Fails with invalid abm token + invalidABMTokenID := uint(9999) + _, err = ds.InsertADUEEnrollmentChallenge(ctx, &invalidABMTokenID, idpUUID, time.Hour) + require.Error(t, err) + + // Fails with empty/invalid idpUUID + _, err = ds.InsertADUEEnrollmentChallenge(ctx, &abmTok.ID, "", time.Hour) + require.Error(t, err) + _, err = ds.InsertADUEEnrollmentChallenge(ctx, &abmTok.ID, "invalid-uuid", time.Hour) + require.Error(t, err) + + // Succeeds with nil abmTokenID + _, err = ds.InsertADUEEnrollmentChallenge(ctx, nil, idpUUID, time.Hour) + require.NoError(t, err) +} + +func testConsumeADUEEnrollmentChallenge(t *testing.T, ds *Datastore) { + ctx := t.Context() + + // Shared prerequisites. + abmTok, err := ds.InsertABMToken(ctx, &fleet.ABMToken{ + OrganizationName: "adue-consume-org", + EncryptedToken: []byte(uuid.NewString()), + RenewAt: time.Now().Add(365 * 24 * time.Hour), + }) + require.NoError(t, err) + + idpUUID := uuid.NewString() + err = ds.InsertMDMIdPAccount(ctx, &fleet.MDMIdPAccount{ + UUID: idpUUID, + Username: "adue-consume-user", + Fullname: "ADUE Consume User", + Email: "adue-consume-user@example.com", + }) + require.NoError(t, err) + + // insertExpiredChallenge bypasses InsertADUEEnrollmentChallenge's positive-duration guard + // to create a challenge that is already past its expires_at. + insertExpiredChallenge := func(t *testing.T) string { + t.Helper() + challenge := uuid.NewString() + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, + `INSERT INTO mdm_adue_enrollment_challenges (challenge, abm_token_id, idp_account_uuid, expires_at) VALUES (?, ?, ?, ?)`, + challenge, abmTok.ID, idpUUID, time.Now().Add(-time.Hour)) + return err + }) + return challenge + } + + t.Run("happy path: marks used_at and returns challenge", func(t *testing.T) { + challenge, err := ds.InsertADUEEnrollmentChallenge(ctx, &abmTok.ID, idpUUID, time.Hour) + require.NoError(t, err) + + before, err := ds.GetADUEEnrollmentChallenge(ctx, challenge) + require.NoError(t, err) + require.Nil(t, before.UsedAt) + + got, err := ds.ConsumeADUEEnrollmentChallenge(ctx, challenge) + require.NoError(t, err) + require.NotNil(t, got) + require.Equal(t, before.ID, got.ID) + + after, err := ds.GetADUEEnrollmentChallenge(ctx, challenge) + require.NoError(t, err) + require.NotNil(t, after.UsedAt) + }) + + t.Run("already used: returns BadRequestError", func(t *testing.T) { + challenge, err := ds.InsertADUEEnrollmentChallenge(ctx, &abmTok.ID, idpUUID, time.Hour) + require.NoError(t, err) + + // First consume succeeds. + _, err = ds.ConsumeADUEEnrollmentChallenge(ctx, challenge) + require.NoError(t, err) + + // Second consume must fail because used_at is now set. + _, err = ds.ConsumeADUEEnrollmentChallenge(ctx, challenge) + require.Error(t, err) + var badReqErr *fleet.BadRequestError + require.ErrorAs(t, err, &badReqErr) + require.Contains(t, badReqErr.Message, "can only be used once") + }) + + t.Run("expired: returns BadRequestError", func(t *testing.T) { + challenge := insertExpiredChallenge(t) + + _, err := ds.ConsumeADUEEnrollmentChallenge(ctx, challenge) + require.Error(t, err) + var badReqErr *fleet.BadRequestError + require.ErrorAs(t, err, &badReqErr) + require.Contains(t, badReqErr.Message, "has expired") + }) + + t.Run("unknown challenge: returns not-found error", func(t *testing.T) { + _, err := ds.ConsumeADUEEnrollmentChallenge(ctx, "no-such-challenge-string") + require.Error(t, err) + require.True(t, fleet.IsNotFound(err)) + }) +} + +func testCleanupExpiredADUEEnrollmentChallenges(t *testing.T, ds *Datastore) { + ctx := t.Context() + + abmTok, err := ds.InsertABMToken(ctx, &fleet.ABMToken{ + OrganizationName: "adue-cleanup-org", + EncryptedToken: []byte(uuid.NewString()), + RenewAt: time.Now().Add(365 * 24 * time.Hour), + }) + require.NoError(t, err) + + idpUUID := uuid.NewString() + err = ds.InsertMDMIdPAccount(ctx, &fleet.MDMIdPAccount{ + UUID: idpUUID, + Username: "adue-cleanup-user", + Fullname: "ADUE Cleanup User", + Email: "adue-cleanup-user@example.com", + }) + require.NoError(t, err) + + now := time.Now() + pastUsedAt := now.Add(-26 * time.Hour) + + insertChallenge := func(t *testing.T, challenge string, expiresAt time.Time, usedAt *time.Time) { + t.Helper() + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + if usedAt != nil { + _, err := q.ExecContext(ctx, + `INSERT INTO mdm_adue_enrollment_challenges (challenge, abm_token_id, idp_account_uuid, expires_at, used_at) VALUES (?, ?, ?, ?, ?)`, + challenge, abmTok.ID, idpUUID, expiresAt, usedAt) + return err + } + _, err := q.ExecContext(ctx, + `INSERT INTO mdm_adue_enrollment_challenges (challenge, abm_token_id, idp_account_uuid, expires_at) VALUES (?, ?, ?, ?)`, + challenge, abmTok.ID, idpUUID, expiresAt) + return err + }) + } + + countChallenge := func(t *testing.T, challenge string) int { + t.Helper() + var count int + ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { + return sqlx.GetContext(ctx, q, &count, + `SELECT COUNT(*) FROM mdm_adue_enrollment_challenges WHERE challenge = ?`, challenge) + }) + return count + } + + // Non-expired, no used_at — must NOT be deleted. + insertChallenge(t, "non-expired-no-used", now.Add(2*time.Hour), nil) + + // Non-expired, has used_at — must NOT be deleted. + insertChallenge(t, "non-expired-with-used", now.Add(2*time.Hour), &pastUsedAt) + + // Expired more than 24 hours ago, no used_at — must be deleted. + insertChallenge(t, "expired-no-used", now.Add(-25*time.Hour), nil) + + // Expired more than 24 hours ago, has used_at — must be deleted. + insertChallenge(t, "expired-with-used", now.Add(-25*time.Hour), &pastUsedAt) + + require.NoError(t, ds.CleanupExpiredADUEEnrollmentChallenges(ctx)) + + assert.Equal(t, 1, countChallenge(t, "non-expired-no-used"), "non-expired challenge without used_at should not be deleted") + assert.Equal(t, 1, countChallenge(t, "non-expired-with-used"), "non-expired challenge with used_at should not be deleted") + assert.Equal(t, 0, countChallenge(t, "expired-no-used"), "challenge expired >24h ago without used_at should be deleted") + assert.Equal(t, 0, countChallenge(t, "expired-with-used"), "challenge expired >24h ago with used_at should be deleted") +} diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index dc5dedbca5b..244b6915e14 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -20,6 +20,8 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" ) +const ADUEEnrollmentChallengeExpiration = 1 * time.Hour + // Sentinel errors for recovery lock rotation var ( // ErrRecoveryLockRotationPending indicates a rotation is already in progress for the host. @@ -1477,3 +1479,11 @@ type HostManagedLocalAccountAutoRotationInfo struct { AccountUUID string `db:"account_uuid"` InitiatedByFleet bool `db:"initiated_by_fleet"` } + +type ADUEEnrollmentChallenge struct { + ID uint `db:"id"` + IdPAccountUUID string `db:"idp_account_uuid"` + ABMTokenID *uint `db:"abm_token_id"` + ExpiresAt time.Time `db:"expires_at"` + UsedAt *time.Time `db:"used_at"` +} diff --git a/server/fleet/cron_schedules.go b/server/fleet/cron_schedules.go index f9a7e8caf91..cf9261a6b37 100644 --- a/server/fleet/cron_schedules.go +++ b/server/fleet/cron_schedules.go @@ -58,6 +58,7 @@ const ( CronSendManagedLocalAccountRotationCommands CronScheduleName = "send_managed_local_account_rotation_commands" CronAppleMDMWorker CronScheduleName = "apple_mdm_worker" CronChartDataCollection CronScheduleName = "chart_data_collection" // Used by chart bounded context + CronCleanupExpiredADUEChallenges CronScheduleName = "cleanup_expired_adue_challenges" ) type CronSchedulesService interface { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 80799dfe63a..906d2999d93 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1998,6 +1998,9 @@ type Datastore interface { // its unique name (the organization name). GetABMTokenByOrgName(ctx context.Context, orgName string) (*ABMToken, error) + // GetABMTokenByUniqueToken retrieves the ABM token by its enrollment_url_token value. + GetABMTokenByUniqueToken(ctx context.Context, uniqueToken string) (*ABMToken, error) + // SaveABMToken updates the ABM token using the provided struct. SaveABMToken(ctx context.Context, tok *ABMToken) error @@ -3449,6 +3452,17 @@ type Datastore interface { // SetTraceSamplerSettings updates the singleton trace_sampler_settings row. The caller is responsible for validating that // ratios are in [0, 1]. The DB CHECK constraints reject out of range writes as a backstop. SetTraceSamplerSettings(ctx context.Context, settings *tracing.Settings) error + + // InsertADUEEnrollmentChallenge generates and inserts a new challenge for Apple Device User Enrollment (ADUE) enrollment, + // associated with the given ABM token ID (if nil = unassigned) and MDM IdP account UUID, and with the specified expiration duration. + // Returns the generated challenge string. + InsertADUEEnrollmentChallenge(ctx context.Context, abmTokenID *uint, idpAccountUUID string, expiration time.Duration) (challenge string, err error) + // GetADUEEnrollmentChallenge retrieves the ADUE enrollment challenge by its challenge value. + GetADUEEnrollmentChallenge(ctx context.Context, challenge string) (*ADUEEnrollmentChallenge, error) + // ConsumeADUEEnrollmentChallenge, consumes (used_at=NOW()) the challenge row, so it can't be re-used. + ConsumeADUEEnrollmentChallenge(ctx context.Context, challenge string) (*ADUEEnrollmentChallenge, error) + // CleanupExpiredADUEEnrollmentChallenges deletes enrollment challenges expired more than 1 day ago. + CleanupExpiredADUEEnrollmentChallenges(ctx context.Context) error } type AndroidDatastore interface { diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index ba755c5008c..dc1c40dd79e 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -181,7 +181,9 @@ type ABMToken struct { MacOSDefaultTeamID *uint `db:"macos_default_team_id" json:"-"` IOSDefaultTeamID *uint `db:"ios_default_team_id" json:"-"` IPadOSDefaultTeamID *uint `db:"ipados_default_team_id" json:"-"` + BYODDefaultTeamID *uint `db:"byod_default_team_id" json:"-"` EncryptedToken []byte `db:"token" json:"-"` + EnrollmentURLToken []byte `db:"enrollment_url_token" json:"-"` // MDMServerURL is not a database field, it is computed from the AppConfig's // Server URL and the static path to the MDM endpoint (using diff --git a/server/fleet/service.go b/server/fleet/service.go index 3f77adcb70f..d4203dafcd8 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -233,7 +233,7 @@ type Service interface { MDMSSOCallback(ctx context.Context, sessionID string, samlResponse []byte) (redirectURL, byodCookieValue string) // GetMDMAccountDrivenEnrollmentSSOURL returns the URL to redirect to for MDM Account Driven Enrollment SSO Authentication - GetMDMAccountDrivenEnrollmentSSOURL(ctx context.Context) (string, error) + GetMDMAccountDrivenEnrollmentSSOURL(ctx context.Context, enrollmentToken string) (string, error) // GetSSOUser handles retrieval of an user that is trying to authenticate // via SSO diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index 02b05ce9ad6..ee8a9f47886 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "crypto/rand" + "crypto/x509" "encoding/json" + "encoding/pem" "errors" "fmt" "log/slog" @@ -23,6 +25,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" "github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/google/uuid" "github.com/hashicorp/go-multierror" @@ -37,13 +40,19 @@ const ( SCEPPath = "/mdm/apple/scep" // MDMPath is Fleet's HTTP path for the core MDM service. MDMPath = "/mdm/apple/mdm" - // MDMServiceDiscoveryPath is Fleet's HTTP path for the MDM service discovery service. - ServiceDiscoveryPath = "/mdm/apple/service_discovery" + // MDMServiceDiscoveryPath is Fleet's base HTTP path for the MDM service discovery service. And is kept for backwards compatible reasons. + // + // Deprecated: Use ServiceDiscoveryTokenPath instead. + ServiceDiscoveryPath = "/mdm/apple/service_discovery" + ServiceDiscoveryTokenPath = "/mdm/apple/service_discovery/{token}" // nolint:gosec // Not a secret // EnrollPath is the HTTP path that serves the mobile profile to devices when enrolling. EnrollPath = "/api/mdm/apple/enroll" // AccountDrivenEnrollPath is the HTTP path that serves the mobile profile to devices when enrolling. - AccountDrivenEnrollPath = "/api/mdm/apple/account_driven_enroll" + // + // Deprecated: Use AccountDrivenEnrollTokenPath instead. + AccountDrivenEnrollPath = "/api/mdm/apple/account_driven_enroll" + AccountDrivenEnrollTokenPath = "/api/mdm/apple/account_driven_enroll/{token}" // nolint:gosec // Not a secret // InstallerPath is the HTTP path that serves installers to Apple devices. InstallerPath = "/api/mdm/apple/installer" @@ -2304,3 +2313,29 @@ func GenerateRecoveryLockPassword() string { return strings.Join(groups, "-") } + +func MDMPushCertTopic(ctx context.Context, ds fleet.MDMAssetRetriever) (string, error) { + assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ + fleet.MDMAssetAPNSCert, + }, nil) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "loading SCEP keypair from the database") + } + + block, _ := pem.Decode(assets[fleet.MDMAssetAPNSCert].Value) + if block == nil || block.Type != "CERTIFICATE" { + return "", ctxerr.New(ctx, "decoding APNs certificate PEM data") + } + + apnsCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "parsing APNs certificate") + } + + mdmPushCertTopic, err := cryptoutil.TopicFromCert(apnsCert) + if err != nil { + return "", ctxerr.Wrap(ctx, err, "extracting topic from APNs certificate") + } + + return mdmPushCertTopic, nil +} diff --git a/server/mdm/lifecycle/lifecycle.go b/server/mdm/lifecycle/lifecycle.go index e917014c02e..48850d9a274 100644 --- a/server/mdm/lifecycle/lifecycle.go +++ b/server/mdm/lifecycle/lifecycle.go @@ -46,6 +46,8 @@ type HostOptions struct { HasSetupExperienceItems bool SCEPRenewalInProgress bool FromMDMMigration bool + // TeamID is currently only used for resetApple to assign the host to the correct team for account driven enrollments. + TeamID *uint } // HostLifecycle manages MDM host lifecycle actions @@ -157,6 +159,7 @@ func (t *HostLifecycle) resetApple(ctx context.Context, opts HostOptions) error HardwareSerial: opts.HardwareSerial, HardwareModel: opts.HardwareModel, Platform: opts.Platform, + TeamID: opts.TeamID, } // FIXME: Why skip this step if we're in the middle of a SCEP renewal? diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index def9924d536..a6db5b06668 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1258,6 +1258,8 @@ type DeleteCAConfigAssetsFunc func(ctx context.Context, names []string) error type GetABMTokenByOrgNameFunc func(ctx context.Context, orgName string) (*fleet.ABMToken, error) +type GetABMTokenByUniqueTokenFunc func(ctx context.Context, uniqueToken string) (*fleet.ABMToken, error) + type SaveABMTokenFunc func(ctx context.Context, tok *fleet.ABMToken) error type InsertVPPTokenFunc func(ctx context.Context, tok *fleet.VPPTokenData) (*fleet.VPPTokenDB, error) @@ -2098,6 +2100,14 @@ type GetTraceSamplerSettingsFunc func(ctx context.Context) (*tracing.Settings, e type SetTraceSamplerSettingsFunc func(ctx context.Context, settings *tracing.Settings) error +type InsertADUEEnrollmentChallengeFunc func(ctx context.Context, abmTokenID *uint, idpAccountUUID string, expiration time.Duration) (challenge string, err error) + +type GetADUEEnrollmentChallengeFunc func(ctx context.Context, challenge string) (*fleet.ADUEEnrollmentChallenge, error) + +type ConsumeADUEEnrollmentChallengeFunc func(ctx context.Context, challenge string) (*fleet.ADUEEnrollmentChallenge, error) + +type CleanupExpiredADUEEnrollmentChallengesFunc func(ctx context.Context) error + type DataStore struct { AppConfigFunc AppConfigFunc AppConfigFuncInvoked bool @@ -3950,6 +3960,9 @@ type DataStore struct { GetABMTokenByOrgNameFunc GetABMTokenByOrgNameFunc GetABMTokenByOrgNameFuncInvoked bool + GetABMTokenByUniqueTokenFunc GetABMTokenByUniqueTokenFunc + GetABMTokenByUniqueTokenFuncInvoked bool + SaveABMTokenFunc SaveABMTokenFunc SaveABMTokenFuncInvoked bool @@ -5210,6 +5223,18 @@ type DataStore struct { SetTraceSamplerSettingsFunc SetTraceSamplerSettingsFunc SetTraceSamplerSettingsFuncInvoked bool + InsertADUEEnrollmentChallengeFunc InsertADUEEnrollmentChallengeFunc + InsertADUEEnrollmentChallengeFuncInvoked bool + + GetADUEEnrollmentChallengeFunc GetADUEEnrollmentChallengeFunc + GetADUEEnrollmentChallengeFuncInvoked bool + + ConsumeADUEEnrollmentChallengeFunc ConsumeADUEEnrollmentChallengeFunc + ConsumeADUEEnrollmentChallengeFuncInvoked bool + + CleanupExpiredADUEEnrollmentChallengesFunc CleanupExpiredADUEEnrollmentChallengesFunc + CleanupExpiredADUEEnrollmentChallengesFuncInvoked bool + mu sync.Mutex } @@ -9532,6 +9557,13 @@ func (s *DataStore) GetABMTokenByOrgName(ctx context.Context, orgName string) (* return s.GetABMTokenByOrgNameFunc(ctx, orgName) } +func (s *DataStore) GetABMTokenByUniqueToken(ctx context.Context, uniqueToken string) (*fleet.ABMToken, error) { + s.mu.Lock() + s.GetABMTokenByUniqueTokenFuncInvoked = true + s.mu.Unlock() + return s.GetABMTokenByUniqueTokenFunc(ctx, uniqueToken) +} + func (s *DataStore) SaveABMToken(ctx context.Context, tok *fleet.ABMToken) error { s.mu.Lock() s.SaveABMTokenFuncInvoked = true @@ -12471,3 +12503,31 @@ func (s *DataStore) SetTraceSamplerSettings(ctx context.Context, settings *traci s.mu.Unlock() return s.SetTraceSamplerSettingsFunc(ctx, settings) } + +func (s *DataStore) InsertADUEEnrollmentChallenge(ctx context.Context, abmTokenID *uint, idpAccountUUID string, expiration time.Duration) (challenge string, err error) { + s.mu.Lock() + s.InsertADUEEnrollmentChallengeFuncInvoked = true + s.mu.Unlock() + return s.InsertADUEEnrollmentChallengeFunc(ctx, abmTokenID, idpAccountUUID, expiration) +} + +func (s *DataStore) GetADUEEnrollmentChallenge(ctx context.Context, challenge string) (*fleet.ADUEEnrollmentChallenge, error) { + s.mu.Lock() + s.GetADUEEnrollmentChallengeFuncInvoked = true + s.mu.Unlock() + return s.GetADUEEnrollmentChallengeFunc(ctx, challenge) +} + +func (s *DataStore) ConsumeADUEEnrollmentChallenge(ctx context.Context, challenge string) (*fleet.ADUEEnrollmentChallenge, error) { + s.mu.Lock() + s.ConsumeADUEEnrollmentChallengeFuncInvoked = true + s.mu.Unlock() + return s.ConsumeADUEEnrollmentChallengeFunc(ctx, challenge) +} + +func (s *DataStore) CleanupExpiredADUEEnrollmentChallenges(ctx context.Context) error { + s.mu.Lock() + s.CleanupExpiredADUEEnrollmentChallengesFuncInvoked = true + s.mu.Unlock() + return s.CleanupExpiredADUEEnrollmentChallengesFunc(ctx) +} diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 380adf1672b..937b895782f 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -104,7 +104,7 @@ type InitSSOCallbackFunc func(ctx context.Context, sessionID string, samlRespons type MDMSSOCallbackFunc func(ctx context.Context, sessionID string, samlResponse []byte) (redirectURL string, byodCookieValue string) -type GetMDMAccountDrivenEnrollmentSSOURLFunc func(ctx context.Context) (string, error) +type GetMDMAccountDrivenEnrollmentSSOURLFunc func(ctx context.Context, enrollmentToken string) (string, error) type GetSSOUserFunc func(ctx context.Context, auth fleet.Auth) (*fleet.User, error) @@ -2618,11 +2618,11 @@ func (s *Service) MDMSSOCallback(ctx context.Context, sessionID string, samlResp return s.MDMSSOCallbackFunc(ctx, sessionID, samlResponse) } -func (s *Service) GetMDMAccountDrivenEnrollmentSSOURL(ctx context.Context) (string, error) { +func (s *Service) GetMDMAccountDrivenEnrollmentSSOURL(ctx context.Context, enrollmentToken string) (string, error) { s.mu.Lock() s.GetMDMAccountDrivenEnrollmentSSOURLFuncInvoked = true s.mu.Unlock() - return s.GetMDMAccountDrivenEnrollmentSSOURLFunc(ctx) + return s.GetMDMAccountDrivenEnrollmentSSOURLFunc(ctx, enrollmentToken) } func (s *Service) GetSSOUser(ctx context.Context, auth fleet.Auth) (*fleet.User, error) { diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 866e436857e..0dbeae1552f 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -8,7 +8,6 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" - "encoding/pem" "errors" "fmt" "io" @@ -29,6 +28,7 @@ import ( "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server" platform_http "github.com/fleetdm/fleet/v4/server/platform/http" + "github.com/gorilla/mux" "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" @@ -47,7 +47,6 @@ import ( mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage" - "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/profiles" @@ -2090,43 +2089,11 @@ func mdmAppleEnrollEndpoint(ctx context.Context, request interface{}, svc fleet. }, nil } -// This endpoint gets called twice by the Apple account driven enrollment flow. The first time it -// is called without a bearer token which results in a 401 Unauthorized response where we tell it -// to go through MDM SSO End User Authentication. The second time it is called with a bearer token, -// in this case an enrollment reference which is used to fetch the enrollment profile. The device -// then has the user sign in with the Apple ID specified in the enrollment profile -func mdmAppleAccountEnrollEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { - req := request.(*mdmAppleAccountEnrollRequest) - svc.SkipAuth(ctx) - deviceProduct := strings.ToLower(req.DeviceInfo.Product) - if !(strings.HasPrefix(deviceProduct, "ipad") || strings.HasPrefix(deviceProduct, "iphone") || strings.HasPrefix(deviceProduct, "ipod")) { - // There is unfortunately no good way to get the client to show this error, they will see a - // generic error about a failure to get an enrollment profile. - return mdmAppleEnrollResponse{ - Err: &fleet.BadRequestError{ - Message: "only iOS and iPadOS devices are supported for account driven user enrollment", - }, - }, nil - } - if req.EnrollReference == nil { - mdmSSOUrl, err := svc.GetMDMAccountDrivenEnrollmentSSOURL(ctx) - if err != nil { - return nil, ctxerr.Wrap(ctx, err) - } - return mdmAppleAccountEnrollAuthenticateResponse{mdmSSOUrl: mdmSSOUrl}, nil - } - - // Fetch the enrollment reference - profile, err := svc.GetMDMAppleAccountEnrollmentProfile(ctx, *req.EnrollReference) - if err != nil { - return mdmAppleEnrollResponse{Err: err}, nil - } - return mdmAppleEnrollResponse{Profile: profile}, nil -} - type mdmAppleAccountEnrollRequest struct { EnrollReference *string DeviceInfo fleet.MDMAppleAccountDrivenUserEnrollDeviceInfo + // EnrollmentToken is the token extracted from the URL variable. It contains the ABM unique token to link this enrollment attempt to an ABM token. + EnrollmentToken string } func (mdmAppleAccountEnrollRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) { @@ -2161,17 +2128,22 @@ func (mdmAppleAccountEnrollRequest) DecodeRequest(ctx context.Context, r *http.R decoded.EnrollReference = ptr.String(strings.Split(auth, "Bearer ")[1]) } + token := mux.Vars(r)["token"] + if token != "" { + decoded.EnrollmentToken = token + } + return &decoded, nil } -type mdmAppleAccountEnrollAuthenticateResponse struct { +type mdmAppleAccountEnrollResponse struct { Err error `json:"error,omitempty"` mdmSSOUrl string } -func (r mdmAppleAccountEnrollAuthenticateResponse) Error() error { return r.Err } +func (r mdmAppleAccountEnrollResponse) Error() error { return r.Err } -func (r mdmAppleAccountEnrollAuthenticateResponse) HijackRender(ctx context.Context, w http.ResponseWriter) { +func (r mdmAppleAccountEnrollResponse) HijackRender(ctx context.Context, w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", `Bearer method="apple-as-web" `+ `url="`+r.mdmSSOUrl+`"`, @@ -2179,66 +2151,59 @@ func (r mdmAppleAccountEnrollAuthenticateResponse) HijackRender(ctx context.Cont w.WriteHeader(http.StatusUnauthorized) } -func (svc *Service) SkipAuth(ctx context.Context) { - svc.authz.SkipAuthorization(ctx) -} - -func (svc *Service) GetMDMAccountDrivenEnrollmentSSOURL(ctx context.Context) (string, error) { - // skipauth: The enroll profile endpoint is unauthenticated. - svc.authz.SkipAuthorization(ctx) - - appConfig, err := svc.ds.AppConfig(ctx) - if err != nil { - return "", ctxerr.Wrap(ctx, err) +// This endpoint gets called twice by the Apple account driven enrollment flow. The first time it +// is called without a bearer token which results in a 401 Unauthorized response where we tell it +// to go through MDM SSO End User Authentication. The second time it is called with a bearer token, +// in this case an enrollment reference which is used to fetch the enrollment profile. The device +// then has the user sign in with the Apple ID specified in the enrollment profile +func mdmAppleAccountEnrollEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*mdmAppleAccountEnrollRequest) + svc.SkipAuth(ctx) + deviceProduct := strings.ToLower(req.DeviceInfo.Product) + if !(strings.HasPrefix(deviceProduct, "ipad") || strings.HasPrefix(deviceProduct, "iphone") || strings.HasPrefix(deviceProduct, "ipod")) { + // There is unfortunately no good way to get the client to show this error, they will see a + // generic error about a failure to get an enrollment profile. + return mdmAppleEnrollResponse{ + Err: &fleet.BadRequestError{ + Message: "only iOS and iPadOS devices are supported for account driven user enrollment", + }, + }, nil } - return appConfig.MDMUrl() + "/mdm/apple/account_driven_enroll/sso", nil -} - -func (svc *Service) GetMDMAppleAccountEnrollmentProfile(ctx context.Context, enrollRef string) (profile []byte, err error) { - // skipauth: This enrollment endpoint is authenticated only by the enrollment reference. - svc.authz.SkipAuthorization(ctx) - - idpAccount, err := svc.ds.GetMDMIdPAccountByUUID(ctx, enrollRef) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "getting MDM IdP account by UUID") + if req.EnrollReference == nil { + mdmSSOUrl, err := svc.GetMDMAccountDrivenEnrollmentSSOURL(ctx, req.EnrollmentToken) + if err != nil { + return mdmAppleAccountEnrollResponse{Err: err}, nil + } + return mdmAppleAccountEnrollResponse{mdmSSOUrl: mdmSSOUrl}, nil } - appConfig, err := svc.ds.AppConfig(ctx) + // Fetch the enrollment reference + profile, err := svc.GetMDMAppleAccountEnrollmentProfile(ctx, *req.EnrollReference) if err != nil { - return nil, ctxerr.Wrap(ctx, err) + return mdmAppleEnrollResponse{Err: err}, nil } + return mdmAppleEnrollResponse{Profile: profile}, nil +} - topic, err := svc.mdmPushCertTopic(ctx) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert") - } +func (svc *Service) SkipAuth(ctx context.Context) { + svc.authz.SkipAuthorization(ctx) +} - assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ - fleet.MDMAssetSCEPChallenge, - }, nil) - if err != nil { - return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err) - } - enrollURL := appConfig.MDMUrl() +func (svc *Service) GetMDMAccountDrivenEnrollmentSSOURL(ctx context.Context, enrollmentToken string) (string, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) - enrollmentProf, err := apple_mdm.GenerateAccountDrivenEnrollmentProfileMobileconfig( - appConfig.OrgInfo.OrgName, - enrollURL, - string(assets[fleet.MDMAssetSCEPChallenge].Value), - topic, - idpAccount.Email, - ) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "generating enrollment profile") - } + return "", fleet.ErrMissingLicense +} - signed, err := mdmcrypto.Sign(ctx, enrollmentProf, svc.ds) - if err != nil { - return nil, ctxerr.Wrap(ctx, err, "signing profile") - } +func (svc *Service) GetMDMAppleAccountEnrollmentProfile(ctx context.Context, enrollRef string) (profile []byte, err error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) - return signed, nil + return nil, fleet.ErrMissingLicense } func (svc *Service) ReconcileMDMAppleEnrollRef(ctx context.Context, enrollRef string, machineInfo *fleet.MDMAppleMachineInfo) (string, error) { @@ -2286,7 +2251,7 @@ func (svc *Service) GetMDMAppleEnrollmentProfileByToken(ctx context.Context, tok return nil, ctxerr.Wrap(ctx, err, "adding reference to fleet URL") } - topic, err := svc.mdmPushCertTopic(ctx) + topic, err := apple_mdm.MDMPushCertTopic(ctx, svc.ds) if err != nil { return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert") } @@ -2539,32 +2504,6 @@ func (svc *Service) getAppleSoftwareUpdateRequiredForDEPEnrollment(m fleet.MDMAp }), nil } -func (svc *Service) mdmPushCertTopic(ctx context.Context) (string, error) { - assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{ - fleet.MDMAssetAPNSCert, - }, nil) - if err != nil { - return "", ctxerr.Wrap(ctx, err, "loading SCEP keypair from the database") - } - - block, _ := pem.Decode(assets[fleet.MDMAssetAPNSCert].Value) - if block == nil || block.Type != "CERTIFICATE" { - return "", ctxerr.Wrap(ctx, err, "decoding PEM data") - } - - apnsCert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return "", ctxerr.Wrap(ctx, err, "parsing APNs certificate") - } - - mdmPushCertTopic, err := cryptoutil.TopicFromCert(apnsCert) - if err != nil { - return "", ctxerr.Wrap(ctx, err, "extracting topic from APNs certificate") - } - - return mdmPushCertTopic, nil -} - // enqueueMDMAppleCommandRemoveEnrollmentProfile enqueues a RemoveProfile MDM command for the given host. // It is a no-op for non-Apple hosts. func (svc *Service) enqueueMDMAppleCommandRemoveEnrollmentProfile(ctx context.Context, host *fleet.Host) error { @@ -3555,7 +3494,7 @@ func (r initiateMDMSSOResponse) SetCookies(_ context.Context, w http.ResponseWri setSSOCookie(w, r.sessionID, r.sessionDurationSeconds) } -func initiateMDMSSOEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { +func initiateMDMSSOEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { req := request.(*initiateMDMSSORequest) sessionID, sessionDurationSeconds, idpProviderURL, err := svc.InitiateMDMSSO(ctx, req.Initiator, "", req.HostUUID) if err != nil { @@ -3563,8 +3502,7 @@ func initiateMDMSSOEndpoint(ctx context.Context, request interface{}, svc fleet. } return initiateMDMSSOResponse{ - URL: idpProviderURL, - + URL: idpProviderURL, sessionID: sessionID, sessionDurationSeconds: sessionDurationSeconds, }, nil @@ -3785,6 +3723,28 @@ func (svc *MDMAppleCheckinAndCommandService) Authenticate(r *mdm.Request, m *mdm m.Model = m.ProductName } + // For account driven user enrollments, we get the challenge in the Bearer token + // which is used to look up the default team for BYOD enrollments. + var byodTeamID *uint + if r.Type == mdm.UserEnrollmentDevice && strings.HasPrefix(r.Authorization, "Bearer ") { + // Split enrollment challenge off the Bearer prefix + challenge := strings.TrimPrefix(r.Authorization, "Bearer ") + + enrollChallenge, err := svc.ds.GetADUEEnrollmentChallenge(r.Context, challenge) + if err != nil { + return ctxerr.Wrap(r.Context, err, "getting adue enrollment challenge") + } + + if enrollChallenge.ABMTokenID != nil { + abmToken, err := svc.ds.GetABMTokenByID(r.Context, *enrollChallenge.ABMTokenID) + if err != nil { + return ctxerr.Wrap(r.Context, err, "getting abm token by id") + } + byodTeamID = abmToken.BYODDefaultTeamID + } + + } + if err := svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{ Action: mdmlifecycle.HostActionReset, Platform: platform, @@ -3793,6 +3753,7 @@ func (svc *MDMAppleCheckinAndCommandService) Authenticate(r *mdm.Request, m *mdm HardwareModel: m.Model, SCEPRenewalInProgress: scepRenewalInProgress, UserEnrollmentID: m.EnrollmentID, + TeamID: byodTeamID, }); err != nil { svc.logger.WarnContext(r.Context, "could not reset Apple mdm information", "UDID", m.UDID, "EnrollmentID", m.EnrollmentID, "err", err) return err @@ -3937,26 +3898,38 @@ func (svc *MDMAppleCheckinAndCommandService) TokenUpdate(r *mdm.Request, m *mdm. } // User (Device) enrollments, also known as Account Driven enrollments or BYOD enrollments, - // are a special case where the bearer token is used to link the enrollment to the IDP account. + // are a special case where the bearer token is used to link the enrollment to the IDP account and it's default team. if r.Type == mdm.UserEnrollmentDevice && idp == nil && strings.HasPrefix(r.Authorization, "Bearer ") { - // Split off the Bearer prefix - accountUUID := strings.TrimPrefix(r.Authorization, "Bearer ") - idpAccount, err := svc.ds.GetMDMIdPAccountByUUID(r.Context, accountUUID) + // Split enrollment challenge off the Bearer prefix + challenge := strings.TrimPrefix(r.Authorization, "Bearer ") + + enrollChallenge, err := svc.ds.GetADUEEnrollmentChallenge(r.Context, challenge) if err != nil && !fleet.IsNotFound(err) { - return ctxerr.Wrap(r.Context, err, "getting idp account by UUID") + return ctxerr.Wrap(r.Context, err, "getting adue enrollment challenge") } - if fleet.IsNotFound(err) || idpAccount == nil { - // This should never happen but we still want to process the token update - svc.logger.ErrorContext(r.Context, "no IDP account found for User (Device) enrollment even though a bearer token was passed", - "host_uuid", r.ID, "account_uuid", accountUUID) + if fleet.IsNotFound(err) || enrollChallenge == nil { + // This should never happen, but we skip IDP assocation. + svc.logger.ErrorContext(r.Context, "no enrollment challenge found for User (Device) enrollment", + "host_uuid", r.ID, "challenge", challenge) } else { - acctUUID = idpAccount.UUID - managedAppleID = idpAccount.Email - err = svc.ds.AssociateHostMDMIdPAccount(r.Context, r.ID, acctUUID) - if err != nil { - return ctxerr.Wrap(r.Context, err, "associating host with idp account") + idpAccount, err := svc.ds.GetMDMIdPAccountByUUID(r.Context, enrollChallenge.IdPAccountUUID) + if err != nil && !fleet.IsNotFound(err) { + return ctxerr.Wrap(r.Context, err, "getting idp account by UUID") + } + if fleet.IsNotFound(err) || idpAccount == nil { + // This should never happen but we still want to process the token update + svc.logger.ErrorContext(r.Context, "no IDP account found for User (Device) enrollment", + "host_uuid", r.ID, "account_uuid", enrollChallenge.IdPAccountUUID) + } else { + acctUUID = idpAccount.UUID + managedAppleID = idpAccount.Email + err = svc.ds.AssociateHostMDMIdPAccount(r.Context, r.ID, acctUUID) + if err != nil { + return ctxerr.Wrap(r.Context, err, "associating host with idp account") + } } } + } // For Account-Driven User Enrollment (BYOD iOS/iPadOS), keep host_mdm's @@ -6859,7 +6832,7 @@ func (svc *Service) MDMAppleProcessOTAEnrollment( return nil, authz.ForbiddenWithInternal(fmt.Sprintf("payload signed with invalid certificate: %s", err), nil, nil, nil) } - topic, err := svc.mdmPushCertTopic(ctx) + topic, err := apple_mdm.MDMPushCertTopic(ctx, svc.ds) if err != nil { return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert") } @@ -6923,7 +6896,7 @@ func EnsureMDMAppleServiceDiscovery(ctx context.Context, ds fleet.Datastore, dep if err != nil { return ctxerr.Wrap(ctx, err, "checking account driven enrollment service discovery") } - sdURL := ac.MDMUrl() + urlPrefix + apple_mdm.ServiceDiscoveryPath + sdURL := ac.MDMUrl() + urlPrefix + apple_mdm.ServiceDiscoveryTokenPath tokens, err := ds.ListABMTokens(ctx) switch { @@ -6932,36 +6905,43 @@ func EnsureMDMAppleServiceDiscovery(ctx context.Context, ds fleet.Datastore, dep case len(tokens) == 0: logger.InfoContext(ctx, "no ABM tokens found, skipping account driven enrollment service discovery") return nil - case len(tokens) > 1: - logger.DebugContext(ctx, "multiple ABM tokens found, using the first one for account driven enrollment service discovery") } - orgName := tokens[0].OrganizationName - details, err := depSvc.GetMDMAppleServiceDiscoveryDetails(ctx, orgName) - if err != nil { - switch { - case godep.IsServiceDiscoveryNotFound(err): - logger.InfoContext(ctx, "account driven enrollment profile not found") // proceed to assignment - case godep.IsServiceDiscoveryNotSupported(err): - logger.InfoContext(ctx, "account driven enrollment org not supported, skipping assignment") - return nil // skip assignment - default: - return ctxerr.Wrap(ctx, err, "fetching account driven enrollment profile") // skip assignment + for _, token := range tokens { + orgName := token.OrganizationName + + details, err := depSvc.GetMDMAppleServiceDiscoveryDetails(ctx, orgName) + if err != nil { + switch { + case godep.IsServiceDiscoveryNotFound(err): + logger.InfoContext(ctx, "account driven enrollment profile not found") // proceed to assignment + case godep.IsServiceDiscoveryNotSupported(err): + logger.InfoContext(ctx, "account driven enrollment org not supported, skipping assignment") + continue // skip assignment + default: + logger.ErrorContext(ctx, "fetching account driven enrollment profile", "org_name", orgName, "err", err) + continue // skip assignment + } } - } - var gotURL string - var lastUpdated time.Time - if details != nil { - gotURL = details.MDMServiceDiscoveryURL - lastUpdated = details.LastUpdatedTimestamp - } - logger.InfoContext(ctx, "account driven enrollment service discovery url confirmed", "service_discovery_url", gotURL, "last_updated", lastUpdated) + sdURLWithToken := strings.Replace(sdURL, "{token}", string(token.EnrollmentURLToken), 1) - if gotURL != sdURL { - // proced to assignment - return ctxerr.Wrap(ctx, depSvc.AssignMDMAppleServiceDiscoveryURL(ctx, orgName, sdURL), - "assigning account driven enrollment service discovery URL") + var gotURL string + var lastUpdated time.Time + if details != nil { + gotURL = details.MDMServiceDiscoveryURL + lastUpdated = details.LastUpdatedTimestamp + } + logger.InfoContext(ctx, "account driven enrollment service discovery url confirmed", "service_discovery_url", gotURL, "last_updated", lastUpdated) + + if gotURL != sdURLWithToken { + logger.InfoContext(ctx, "account driven enrollment service discovery url needs update", "new_url", sdURLWithToken) + // proced to assignment + if err := depSvc.AssignMDMAppleServiceDiscoveryURL(ctx, orgName, sdURLWithToken); err != nil { + logger.ErrorContext(ctx, "assigning account driven enrollment service discovery URL", "org_name", orgName, "err", err) + } + continue + } } return nil diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index d14859785ae..4b3b466d177 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -2381,6 +2381,19 @@ func TestMDMTokenUpdateUserEnrollmentManagedAppleID(t *testing.T) { ds.EnqueueSetupExperienceItemsFunc = func(context.Context, string, string, string, uint) (bool, error) { return false, nil } + ds.GetADUEEnrollmentChallengeFunc = func(ctx context.Context, challenge string) (*fleet.ADUEEnrollmentChallenge, error) { + return &fleet.ADUEEnrollmentChallenge{ + IdPAccountUUID: "idp-uuid", + }, nil + } + ds.GetABMTokenByIDFunc = func(ctx context.Context, tokenID uint) (*fleet.ABMToken, error) { + return &fleet.ABMToken{ + BYODDefaultTeamID: nil, + }, nil + } + ds.AddHostsToTeamFunc = func(ctx context.Context, params *fleet.AddHostsToTeamParams) error { + return nil + } t.Run("UserEnrollmentDevice with linked IDP account persists managed_apple_id", func(t *testing.T) { ds.GetMDMIdPAccountByHostUUIDFunc = func(context.Context, string) (*fleet.MDMIdPAccount, error) { @@ -2417,8 +2430,8 @@ func TestMDMTokenUpdateUserEnrollmentManagedAppleID(t *testing.T) { return nil, nil } ds.GetMDMIdPAccountByUUIDFunc = func(_ context.Context, uuid string) (*fleet.MDMIdPAccount, error) { - require.Equal(t, "bearer-uuid", uuid) - return &fleet.MDMIdPAccount{UUID: "bearer-uuid", Email: "bearer.user@example.com"}, nil + require.Equal(t, "idp-uuid", uuid) + return &fleet.MDMIdPAccount{UUID: "idp-uuid", Email: "bearer.user@example.com"}, nil } ds.AssociateHostMDMIdPAccountFunc = func(context.Context, string, string) error { return nil } ds.SetHostManagedAppleIDFuncInvoked = false @@ -2432,7 +2445,7 @@ func TestMDMTokenUpdateUserEnrollmentManagedAppleID(t *testing.T) { &mdm.Request{ Context: ctx, EnrollID: &mdm.EnrollID{ID: enrollID, Type: mdm.UserEnrollmentDevice}, - Authorization: "Bearer bearer-uuid", + Authorization: "Bearer challenge", }, &mdm.TokenUpdate{ TokenUpdateEnrollment: mdm.TokenUpdateEnrollment{ diff --git a/server/service/handler.go b/server/service/handler.go index e05ec5df215..59b82196bab 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -1076,6 +1076,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC neAppleMDM.GET("/api/_version_/fleet/enrollment_profiles/ota", getOTAProfileEndpoint, getOTAProfileRequest{}) // This is the account-driven enrollment endpoint for BYoD Apple devices, also known as User Enrollment. + neAppleMDM.POST(apple_mdm.AccountDrivenEnrollTokenPath, mdmAppleAccountEnrollEndpoint, mdmAppleAccountEnrollRequest{}) + // Deprecated: Non unique token enrollment is deprecated in favour of AccountDrivenEnrollTokenPath. This is the account-driven enrollment endpoint for BYoD Apple devices, also known as User Enrollment. neAppleMDM.POST(apple_mdm.AccountDrivenEnrollPath, mdmAppleAccountEnrollEndpoint, mdmAppleAccountEnrollRequest{}) // This is for OAUTH2 token based auth // ne.POST(apple_mdm.EnrollPath+"/token", mdmAppleAccountEnrollTokenEndpoint, mdmAppleAccountEnrollTokenRequest{}) @@ -1311,18 +1313,27 @@ func registerMDMServiceDiscovery( fleetConfig config.FleetConfig, ) error { serviceDiscoveryLogger := logger.With("component", "mdm-apple-service-discovery") - fullMDMEnrollmentURL := fmt.Sprintf("%s%s", serverURLPrefix, apple_mdm.AccountDrivenEnrollPath) serviceDiscoveryHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var mdmEnrollmentURL string + token := r.PathValue("token") + if token != "" { + tokenPath := strings.Replace(apple_mdm.AccountDrivenEnrollTokenPath, "{token}", url.PathEscape(token), 1) + mdmEnrollmentURL = serverURLPrefix + tokenPath + } else { + mdmEnrollmentURL = serverURLPrefix + apple_mdm.AccountDrivenEnrollPath // nolint:staticcheck // deprecated path, kept for backwards compatibility + } + ctx := r.Context() - serviceDiscoveryLogger.InfoContext(ctx, "serving MDM service discovery response", "url", fullMDMEnrollmentURL) + serviceDiscoveryLogger.InfoContext(ctx, "serving MDM service discovery response", "url", mdmEnrollmentURL) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(w, `{"Servers":[{"Version": "mdm-byod", "BaseURL": "%s"}]}`, fullMDMEnrollmentURL) + _, err := fmt.Fprintf(w, `{"Servers":[{"Version": "mdm-byod", "BaseURL": "%s"}]}`, mdmEnrollmentURL) if err != nil { serviceDiscoveryLogger.ErrorContext(ctx, "error writing service discovery response", "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } }) + mux.Handle(apple_mdm.ServiceDiscoveryTokenPath, otel.WrapHandler(serviceDiscoveryHandler, apple_mdm.ServiceDiscoveryTokenPath, fleetConfig)) mux.Handle(apple_mdm.ServiceDiscoveryPath, otel.WrapHandler(serviceDiscoveryHandler, apple_mdm.ServiceDiscoveryPath, fleetConfig)) return nil } diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go index fcaf7e7f547..2c6b1272616 100644 --- a/server/service/integration_mdm_lifecycle_test.go +++ b/server/service/integration_mdm_lifecycle_test.go @@ -718,7 +718,7 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { require.NotEmpty(t, enrollSecrets) // ensure there's a token for automatic enrollments - s.enableABM(t.Name()) + abmToken := s.enableABM(t.Name()) // for our tests, we'll crete two ABM devices and some manual ones devices := []godep.Device{ @@ -847,13 +847,42 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { require.NotNil(t, iphoneUser) require.Equal(t, iphoneUser.Email, "iphone_user@example.com") + originalServerURL := s.server.URL + var acResp appConfigResponse + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{ + "server_settings": { + "server_url": "https://localhost:8080" + }, + "mdm": { + "end_user_authentication": { + "entity_id": "mdm.test.com", + "idp_name": "SimpleSAML", + "metadata_url": "%s" + } + } + }`, testSAMLIDPMetadataURL)), http.StatusOK, &acResp) + + iPhoneBYODToken := s.LoginAccountDrivenEnrollUser("sso_user", "user123#", string(abmToken.EnrollmentURLToken)) + loc, err := iPhoneBYODToken.Location() + require.NoError(t, err) + require.NotNil(t, loc) + require.True(t, strings.HasPrefix(loc.String(), "apple-remotemanagement-user-login://authentication-results?access-token=")) + accessToken := strings.Split(loc.String(), "apple-remotemanagement-user-login://authentication-results?access-token=")[1] + + acResp = appConfigResponse{} + s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ + "server_settings": { + "server_url": "`+originalServerURL+`" + } + }`), http.StatusOK, &acResp) + iPhoneMdmDevice := mdmtest.NewTestMDMClientAppleAccountDrivenUserEnrollment( s.server.URL, iPhoneHwModel, - iphoneUser.UUID, + accessToken, ) require.NoError(t, iPhoneMdmDevice.Enroll()) - assert.Equal(t, iPhoneMdmDevice.EnrollInfo.AssignedManagedAppleID, iphoneUser.Email) + assert.Equal(t, "sso_user@example.com", iPhoneMdmDevice.EnrollInfo.AssignedManagedAppleID) // add global profiles globalProfiles := [][]byte{ @@ -995,7 +1024,20 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { require.NoError(t, plist.Unmarshal(renewCmd.Raw, &fullCmd)) if wantProfile == "" { - s.verifyEnrollmentProfile(fullCmd.Command.InstallProfile.Payload, enrollRef, wantManagedAppleID) + enrollProfile := s.verifyEnrollmentProfile(fullCmd.Command.InstallProfile.Payload, enrollRef, wantManagedAppleID) + if wantManagedAppleID != "" { + // we see this as byod, so we update the enrollInfo to avoid fetching the profile againt from the account_driven_enroll path, as that is not how it works. + // and with the new challenge based setup, does not mimic the real flow well. + for _, payload := range enrollProfile.PayloadContent { + switch payload.PayloadType { + case "com.apple.security.scep": + device.EnrollInfo.SCEPURL = payload.PayloadContent.URL + device.EnrollInfo.SCEPChallenge = payload.PayloadContent.Challenge + case "com.apple.mdm": + device.EnrollInfo.MDMURL = payload.ServerURL + } + } + } } else { p7, err := pkcs7.Parse(fullCmd.Command.InstallProfile.Payload) require.NoError(t, err) @@ -1022,7 +1064,7 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { checkRenewCertCommand(manualEnrolledDevice, "", "", "") checkRenewCertCommand(automaticEnrolledDevice, "", "", "") checkRenewCertCommand(automaticEnrolledDeviceWithRef, "foo", "", "") - checkRenewCertCommand(iPhoneMdmDevice, "", "", iphoneUser.Email) + checkRenewCertCommand(iPhoneMdmDevice, "", "", "sso_user@example.com") // migrated device doesn't receive any commands because // `FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE` is not set @@ -1108,7 +1150,7 @@ func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() { require.NoError(t, err) checkRenewCertCommand(automaticEnrolledDevice, "", "", "") checkRenewCertCommand(automaticEnrolledDeviceWithRef, "foo", "", "") - checkRenewCertCommand(iPhoneMdmDevice, "", "", iphoneUser.Email) + checkRenewCertCommand(iPhoneMdmDevice, "", "", "sso_user@example.com") // migrated device is still marked as migrated var stillMigrated bool diff --git a/server/service/integration_mdm_setup_experience_test.go b/server/service/integration_mdm_setup_experience_test.go index b781f8408f9..4a835e740e4 100644 --- a/server/service/integration_mdm_setup_experience_test.go +++ b/server/service/integration_mdm_setup_experience_test.go @@ -5161,8 +5161,9 @@ func (s *integrationMDMTestSuite) TestSetupExperienceBYODiOS() { // device gets the regular MDM endpoints. originalServerURL := s.server.URL s.setUpMDMSSO(t, true) + abmToken := s.enableABM(t.Name()) - ssoResult := s.LoginAccountDrivenEnrollUser("sso_user", "user123#") + ssoResult := s.LoginAccountDrivenEnrollUser("sso_user", "user123#", string(abmToken.EnrollmentURLToken)) loc, err := ssoResult.Location() require.NoError(t, err) require.NotNil(t, loc) @@ -5259,7 +5260,8 @@ func (s *integrationMDMTestSuite) TestSetupExperienceInstallerEditAndDelete() { enrollHostWithSEInstallers := func(t *testing.T, installers []struct { Filename string Title string - }) (*fleet.Host, *mdmtest.TestAppleMDMClient, map[string]uint) { + }, + ) (*fleet.Host, *mdmtest.TestAppleMDMClient, map[string]uint) { // unique per-subtest team name and ABM org so subtests don't collide isoName := strings.ReplaceAll(t.Name(), "/", "_") s.enableABM(isoName) diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index b879dacfa76..3d423f62caf 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -908,6 +908,9 @@ func (s *integrationMDMTestSuite) TearDownTest() { appCfg.MDM.EnabledAndConfigured = true appCfg.MDM.AppleBMEnabledAndConfigured = true appCfg.MDM.AndroidEnabledAndConfigured = true + + appCfg.MDM.EndUserAuthentication = fleet.MDMEndUserAuthentication{} // Reset end user auth + // ensure the server URL is constant appCfg.ServerSettings.ServerURL = s.server.URL err := s.ds.SaveAppConfig(ctx, &appCfg.AppConfig) @@ -16104,9 +16107,10 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { // TODO: Is there a better way to do this? originalServerUrl := s.server.URL s.setUpMDMSSO(t, true) + abmToken := s.enableABM(t.Name()) - getSSOAccessToken := func(username, password string) string { - ssoResult := s.LoginAccountDrivenEnrollUser(username, password) + getSSOAccessToken := func(username, password, token string) string { + ssoResult := s.LoginAccountDrivenEnrollUser(username, password, token) // check location for apple-remotemanagement-user-login://authentication-results?access-token= returnedLocation, err := ssoResult.Location() require.NoError(t, err) @@ -16117,8 +16121,9 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { return accessToken } - iPhoneAccessToken := getSSOAccessToken("sso_user", "user123#") - iPadAccessToken := getSSOAccessToken("sso_user2", "user123#") + iPhoneAccessToken := getSSOAccessToken("sso_user", "user123#", string(abmToken.EnrollmentURLToken)) + iPadAccessToken := getSSOAccessToken("sso_user2", "user123#", string(abmToken.EnrollmentURLToken)) + oldUrlIphoneAccessToken := getSSOAccessToken("sso_user2", "user123#", "") acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ @@ -16141,14 +16146,14 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { iPhoneAccessToken, ) require.NoError(t, iPhoneMdmDevice.Enroll()) - assert.Equal(t, iPhoneMdmDevice.EnrollInfo.AssignedManagedAppleID, "sso_user@example.com") + assert.Equal(t, "sso_user@example.com", iPhoneMdmDevice.EnrollInfo.AssignedManagedAppleID) s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMEnrolled{}.ActivityName(), fmt.Sprintf(`{"host_serial": null, "enrollment_id": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "ios"}`, iPhoneMdmDevice.EnrollmentID(), iPhoneMdmDevice.Model, iPhoneMdmDevice.EnrollmentID()), 0) linkedIDPAccount, err := s.ds.GetMDMIdPAccountByHostUUID(context.Background(), iPhoneMdmDevice.EnrollmentID()) require.NoError(t, err) require.NotNil(t, linkedIDPAccount) - assert.Equal(t, linkedIDPAccount.Email, "sso_user@example.com") + assert.Equal(t, "sso_user@example.com", linkedIDPAccount.Email) iPadHwModel := "iPad14,5" iPadMdmDevice := mdmtest.NewTestMDMClientAppleAccountDrivenUserEnrollment( @@ -16158,13 +16163,25 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { ) require.ErrorContains(t, iPadMdmDevice.Enroll(), "401 Unauthorized") + // create a team we can update the ABM token to default the iPad enrollment to + team, err := s.ds.NewTeam(t.Context(), &fleet.Team{ + Name: "team1", + }) + require.NoError(t, err) + + // TODO: Update to call DS/SVC method + mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(t.Context(), "UPDATE abm_tokens SET byod_default_team_id = ? WHERE id = ?", team.ID, abmToken.ID) + return err + }) + iPadMdmDevice = mdmtest.NewTestMDMClientAppleAccountDrivenUserEnrollment( s.server.URL, iPadHwModel, iPadAccessToken, ) require.NoError(t, iPadMdmDevice.Enroll()) - assert.Equal(t, iPadMdmDevice.EnrollInfo.AssignedManagedAppleID, "sso_user2@example.com") + assert.Equal(t, "sso_user2@example.com", iPadMdmDevice.EnrollInfo.AssignedManagedAppleID) s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMEnrolled{}.ActivityName(), fmt.Sprintf(`{"host_serial": null, "enrollment_id": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "ipados"}`, iPadMdmDevice.EnrollmentID(), iPadMdmDevice.Model, iPadMdmDevice.EnrollmentID()), 0) @@ -16173,6 +16190,21 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { require.NotNil(t, linkedIDPAccount) assert.Equal(t, linkedIDPAccount.Email, "sso_user2@example.com") + oldUrlIphoneMdmDevice := mdmtest.NewTestMDMClientAppleAccountDrivenUserEnrollment( + s.server.URL, + "iPhone14,5", + oldUrlIphoneAccessToken, + ) + require.NoError(t, oldUrlIphoneMdmDevice.Enroll()) + assert.Equal(t, "sso_user2@example.com", oldUrlIphoneMdmDevice.EnrollInfo.AssignedManagedAppleID) + + s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMEnrolled{}.ActivityName(), + fmt.Sprintf(`{"host_serial": null, "enrollment_id": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false, "mdm_platform": "apple", "platform": "ios"}`, oldUrlIphoneMdmDevice.EnrollmentID(), oldUrlIphoneMdmDevice.Model, oldUrlIphoneMdmDevice.EnrollmentID()), 0) + linkedIDPAccount, err = s.ds.GetMDMIdPAccountByHostUUID(context.Background(), oldUrlIphoneMdmDevice.EnrollmentID()) + require.NoError(t, err) + require.NotNil(t, linkedIDPAccount) + assert.Equal(t, "sso_user2@example.com", linkedIDPAccount.Email) + // Account driven enrollment is not yet supported for macOS devices so we expect an error in // either case macbookHwModel := "Mac16,1" @@ -16201,17 +16233,20 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { var iPhoneHostID *uint var iPadHostID *uint s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes) - require.Len(t, listHostsRes.Hosts, 2) + require.Len(t, listHostsRes.Hosts, 3) for _, host := range listHostsRes.Hosts { - if host.UUID == iPhoneMdmDevice.EnrollmentID() { + switch host.UUID { + case iPhoneMdmDevice.EnrollmentID(): assert.Equal(t, iPhoneHwModel, host.HardwareModel) assert.Equal(t, iPhoneMdmDevice.EnrollmentID(), host.UUID) assert.Equal(t, iPhoneMdmDevice.EnrollmentID(), host.HardwareSerial) require.NotNil(t, host.MDM.EnrollmentStatus) assert.Equal(t, "On (personal)", *host.MDM.EnrollmentStatus) assert.True(t, *host.MDM.ConnectedToFleet) - iPhoneHostID = ptr.Uint(host.ID) - } else if host.UUID == iPadMdmDevice.EnrollmentID() { + assert.Nil(t, host.TeamID) + id := host.ID + iPhoneHostID = &id + case iPadMdmDevice.EnrollmentID(): assert.Equal(t, "ipados", host.Platform) assert.Equal(t, iPadMdmDevice.EnrollmentID(), host.UUID) assert.Equal(t, iPadMdmDevice.EnrollmentID(), host.HardwareSerial) @@ -16219,7 +16254,15 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { require.NotNil(t, host.MDM.EnrollmentStatus) assert.Equal(t, "On (personal)", *host.MDM.EnrollmentStatus) assert.True(t, *host.MDM.ConnectedToFleet) - iPadHostID = ptr.Uint(host.ID) + assert.Equal(t, team.ID, *host.TeamID) + id := host.ID + iPadHostID = &id + case oldUrlIphoneMdmDevice.EnrollmentID(): + // Primarily assert it was enrolled correctly and fallback to unassigned + assert.Equal(t, "ios", host.Platform) + assert.Equal(t, "On (personal)", *host.MDM.EnrollmentStatus) + assert.True(t, *host.MDM.ConnectedToFleet) + assert.Nil(t, host.TeamID) } } @@ -16233,6 +16276,7 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { assert.Equal(t, "On (personal)", *getHostResp.Host.MDM.EnrollmentStatus) assert.True(t, *getHostResp.Host.MDM.ConnectedToFleet) assert.Equal(t, iPhoneHwModel, getHostResp.Host.HardwareModel) + assert.Nil(t, getHostResp.Host.HostDetail.TeamID) // Confirm that the host MDM endpoint contains the expected values for the iPhone // using an inline struct because the definition of fleet.HostMDM makes it unusable here @@ -16251,6 +16295,7 @@ func (s *integrationMDMTestSuite) TestAppleMDMAccountDrivenUserEnrollment() { require.NotNil(t, getHostResp.Host.MDM.EnrollmentStatus) assert.Equal(t, "On (personal)", *getHostResp.Host.MDM.EnrollmentStatus) assert.True(t, *getHostResp.Host.MDM.ConnectedToFleet) + assert.Equal(t, team.ID, *getHostResp.Host.TeamID) // Confirm that the host MDM endpoint contains the expected values for the iPad s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", *iPadHostID), nil, http.StatusOK, &getHostMDMResponse) @@ -16268,9 +16313,10 @@ func (s *integrationMDMTestSuite) TestAppleMDMActionsOnPersonalHost() { // TODO: Is there a better way to do this? originalServerUrl := s.server.URL s.setUpMDMSSO(t, true) + abmToken := s.enableABM(t.Name()) getSSOAccessToken := func(username, password string) string { - ssoResult := s.LoginAccountDrivenEnrollUser(username, password) + ssoResult := s.LoginAccountDrivenEnrollUser(username, password, string(abmToken.EnrollmentURLToken)) // check location for apple-remotemanagement-user-login://authentication-results?access-token= returnedLocation, err := ssoResult.Location() require.NoError(t, err) @@ -21066,7 +21112,12 @@ func (s *integrationMDMTestSuite) TestServiceDiscovery() { res := serviceDiscoveryResponse{} s.DoJSON("GET", "/mdm/apple/service_discovery", nil, http.StatusOK, &res) - require.Contains(t, res.Servers[0].BaseURL, apple_mdm.AccountDrivenEnrollPath) + require.Contains(t, res.Servers[0].BaseURL, apple_mdm.AccountDrivenEnrollPath) // nolint:staticcheck // kept for backwards compatible testing + require.Equal(t, "mdm-byod", res.Servers[0].Version) + + // Verify /{token} path works and passes it through + s.DoJSON("GET", "/mdm/apple/service_discovery/fake-token", nil, http.StatusOK, &res) + require.Contains(t, res.Servers[0].BaseURL, strings.Replace(apple_mdm.AccountDrivenEnrollTokenPath, "{token}", "fake-token", 1)) require.Equal(t, "mdm-byod", res.Servers[0].Version) } diff --git a/server/service/testing_client_test.go b/server/service/testing_client_test.go index 2c746c20ade..2b8f02a1bf2 100644 --- a/server/service/testing_client_test.go +++ b/server/service/testing_client_test.go @@ -545,9 +545,13 @@ func (ts *withServer) LoginOTAEnrollSSOUser(username, password, enrollSecret str return resp } -func (ts *withServer) LoginAccountDrivenEnrollUser(username, password string) *http.Response { +func (ts *withServer) LoginAccountDrivenEnrollUser(username, password, token string) *http.Response { + initiator := fleet.SSOInitiatorAccountDrivenEnroll + if token != "" { + initiator = fmt.Sprintf("%s:%s", initiator, token) + } requestParams := initiateMDMSSORequest{ - Initiator: fleet.SSOInitiatorAccountDrivenEnroll, + Initiator: initiator, UserIdentifier: username + "@example.com", } body, err := json.Marshal(requestParams) @@ -596,9 +600,9 @@ func (ts *withServer) loginSSOUser(username, password string, basePath string, c func (ts *withServer) loginSSOUserWithBody(username, password string, basePath string, callbackStatus int, requestBody []byte) *http.Response { t := ts.s.T() - if _, ok := os.LookupEnv("SAML_IDP_TEST"); !ok { + /* if _, ok := os.LookupEnv("SAML_IDP_TEST"); !ok { t.Skip("SSO tests are disabled") - } + } */ cookieSecure = false jar, err := cookiejar.New(nil)