diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 1c303350d8..b180912fff 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -1417,7 +1417,9 @@ func (svc *Service) UninstallSoftwareTitle(ctx context.Context, hostID uint, sof // we need to use ds.Host because ds.HostLite doesn't return the orbit node key host, err := svc.ds.Host(ctx, hostID) - fromMyDevicePage := svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) || svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) + fromMyDevicePage := svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) || + svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) || + svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) if err != nil { // if error is because the host does not exist, check first if the user @@ -1536,7 +1538,9 @@ func (svc *Service) insertSoftwareUninstallRequest(ctx context.Context, executio } func (svc *Service) GetSoftwareInstallResults(ctx context.Context, resultUUID string) (*fleet.HostSoftwareInstallerResult, error) { - if svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) || svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) { + if svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) || + svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) || + svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) { return svc.getDeviceSoftwareInstallResults(ctx, resultUUID) } diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 8bb6de6534..9a20f78adb 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -743,7 +743,9 @@ func (svc *Service) GetTeam(ctx context.Context, teamID uint) (*fleet.Team, erro return team, nil } - alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) || svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) + alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) || + svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) || + svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) if alreadyAuthd { // device-authenticated request can only get the device's team host, ok := hostctx.FromContext(ctx) diff --git a/server/contexts/authz/authz.go b/server/contexts/authz/authz.go index 19aaaef8e2..c59c3e3c64 100644 --- a/server/contexts/authz/authz.go +++ b/server/contexts/authz/authz.go @@ -52,6 +52,9 @@ const ( // backed by the device's SCEP Identity certificate. This authentication method does not support // granular authorization. AuthnHTTPMessageSignature + // AuthnDeviceURL is when authentication is done via a UUID in the URL. + // This authentication mode does not support granular authorization. + AuthnDeviceURL ) // AuthorizationContext contains the context information used for the diff --git a/server/fleet/service.go b/server/fleet/service.go index 2e79c8ea42..04679a8346 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -358,6 +358,10 @@ type Service interface { // This is used for iOS/iPadOS devices accessing My Device page via client certificates. // Returns an error if the certificate doesn't match the host or if the host is not iOS/iPadOS. AuthenticateDeviceByCertificate(ctx context.Context, certSerial uint64, hostUUID string) (host *Host, debug bool, err error) + // AuthenticateIDeviceByURL loads host identified by the URL UUID. + // This is used for iOS/iPadOS devices (iDevices) accessing endpoints via a unique URL parameter. + // Returns an error if the UUID doesn't exist or if the host is not iOS/iPadOS. + AuthenticateIDeviceByURL(ctx context.Context, urlUUID string) (host *Host, debug bool, err error) ListHosts(ctx context.Context, opt HostListOptions) (hosts []*Host, err error) // GetHost returns the host with the provided ID. diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 174caf02a2..9f331d1c9c 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -208,6 +208,8 @@ type AuthenticateDeviceFunc func(ctx context.Context, authToken string) (host *f type AuthenticateDeviceByCertificateFunc func(ctx context.Context, certSerial uint64, hostUUID string) (host *fleet.Host, debug bool, err error) +type AuthenticateIDeviceByURLFunc func(ctx context.Context, urlUUID string) (host *fleet.Host, debug bool, err error) + type ListHostsFunc func(ctx context.Context, opt fleet.HostListOptions) (hosts []*fleet.Host, err error) type GetHostFunc func(ctx context.Context, id uint, opts fleet.HostDetailOptions) (host *fleet.HostDetail, err error) @@ -1152,6 +1154,9 @@ type Service struct { AuthenticateDeviceByCertificateFunc AuthenticateDeviceByCertificateFunc AuthenticateDeviceByCertificateFuncInvoked bool + AuthenticateIDeviceByURLFunc AuthenticateIDeviceByURLFunc + AuthenticateIDeviceByURLFuncInvoked bool + ListHostsFunc ListHostsFunc ListHostsFuncInvoked bool @@ -2807,6 +2812,13 @@ func (s *Service) AuthenticateDeviceByCertificate(ctx context.Context, certSeria return s.AuthenticateDeviceByCertificateFunc(ctx, certSerial, hostUUID) } +func (s *Service) AuthenticateIDeviceByURL(ctx context.Context, urlUUID string) (host *fleet.Host, debug bool, err error) { + s.mu.Lock() + s.AuthenticateIDeviceByURLFuncInvoked = true + s.mu.Unlock() + return s.AuthenticateIDeviceByURLFunc(ctx, urlUUID) +} + func (s *Service) ListHosts(ctx context.Context, opt fleet.HostListOptions) (hosts []*fleet.Host, err error) { s.mu.Lock() s.ListHostsFuncInvoked = true diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 53c5df0190..c13e21d4c3 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -233,7 +233,9 @@ func (svc *Service) SandboxEnabled() bool { } func (svc *Service) AppConfigObfuscated(ctx context.Context) (*fleet.AppConfig, error) { - if !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) && !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) { + if !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) && + !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) && + !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) { if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionRead); err != nil { return nil, err } diff --git a/server/service/devices.go b/server/service/devices.go index 3314ac3ec0..0966ef69c5 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -158,6 +158,25 @@ func getDeviceHostEndpoint(ctx context.Context, request interface{}, svc fleet.S return getDeviceHostResponse{Err: err}, nil } + // Scrub sensitive data from the host response for iOS and iPadOS devices + if authzCtx, ok := authz.FromContext(ctx); ok && authzCtx.AuthnMethod() == authz.AuthnDeviceURL { + if host.Platform == "ios" || host.Platform == "ipados" { + resp.HardwareSerial = "" + resp.UUID = "" + resp.PrimaryMac = "" + resp.TeamName = nil + resp.MDM.Profiles = nil + resp.Labels = nil + + // Scrub sensitive data from the license response + scrubbedLicense := *license + scrubbedLicense.Organization = "" + scrubbedLicense.DeviceCount = 0 + scrubbedLicense.Expiration = time.Time{} + license = &scrubbedLicense + } + } + resp.DEPAssignedToFleet = ptr.Bool(false) if ac.MDM.EnabledAndConfigured && license.IsPremium() { hdep, err := svc.GetHostDEPAssignment(ctx, host) @@ -214,7 +233,9 @@ func getDeviceHostEndpoint(ctx context.Context, request interface{}, svc fleet.S } func (svc *Service) GetHostDEPAssignment(ctx context.Context, host *fleet.Host) (*fleet.HostDEPAssignment, error) { - alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) || svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceCertificate) + alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) || + svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceCertificate) || + svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceURL) if !alreadyAuthd { if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil { return nil, err @@ -304,6 +325,36 @@ func (svc *Service) AuthenticateDeviceByCertificate(ctx context.Context, certSer return host, svc.debugEnabledForHost(ctx, host.ID), nil } +// AuthenticateIDeviceByURL returns the host identified by the URL UUID. +// This is used for iOS/iPadOS devices (iDevices) accessing endpoints via a unique URL parameter. +// Returns an error if the UUID doesn't exist or if the host is not iOS/iPadOS. +func (svc *Service) AuthenticateIDeviceByURL(ctx context.Context, urlUUID string) (*fleet.Host, bool, error) { + // skipauth: Authorization is currently for user endpoints only. + svc.authz.SkipAuthorization(ctx) + + if urlUUID == "" { + return nil, false, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("authentication error: missing host UUID")) + } + + // Look up the host by UUID + host, err := svc.ds.HostByIdentifier(ctx, urlUUID) + switch { + case err == nil: + // OK + case fleet.IsNotFound(err): + return nil, false, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("authentication error: host not found")) + default: + return nil, false, ctxerr.Wrap(ctx, err, "lookup host by UUID") + } + + // Verify host platform is iOS or iPadOS + if host.Platform != "ios" && host.Platform != "ipados" { + return nil, false, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("authentication error: URL authentication only supported for iOS and iPadOS devices")) + } + + return host, svc.debugEnabledForHost(ctx, host.ID), nil +} + ///////////////////////////////////////////////////////////////////////////////// // Refetch Current Device's Host ///////////////////////////////////////////////////////////////////////////////// @@ -684,7 +735,9 @@ func fleetdError(ctx context.Context, request interface{}, svc fleet.Service) (f } func (svc *Service) LogFleetdError(ctx context.Context, fleetdError fleet.FleetdError) error { - if !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) && !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceCertificate) { + // iOS/iPadOS devices don't have fleetd, so URL auth is not allowed here. + if !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) && + !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceCertificate) { return ctxerr.Wrap(ctx, fleet.NewPermissionError("forbidden: only device-authenticated hosts can access this endpoint")) } @@ -739,7 +792,9 @@ func getDeviceMDMManualEnrollProfileEndpoint(ctx context.Context, request interf func (svc *Service) GetDeviceMDMAppleEnrollmentProfile(ctx context.Context) (*url.URL, error) { // must be device-authenticated, no additional authorization is required - if !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) && !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceCertificate) { + // iOS/iPadOS devices are enrolled via MDM profile or ABM, so URL auth is not allowed here. + if !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceToken) && + !svc.authz.IsAuthenticatedWith(ctx, authz.AuthnDeviceCertificate) { return nil, ctxerr.Wrap(ctx, fleet.NewPermissionError("forbidden: only device-authenticated hosts can access this endpoint")) } diff --git a/server/service/devices_endpoint_test.go b/server/service/devices_endpoint_test.go new file mode 100644 index 0000000000..69a4afd680 --- /dev/null +++ b/server/service/devices_endpoint_test.go @@ -0,0 +1,252 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/contexts/authz" + "github.com/fleetdm/fleet/v4/server/contexts/host" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetDeviceHostEndpointScrubbing(t *testing.T) { + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true}) + + h := &fleet.Host{ + ID: 1, + Hostname: "test-host", + UUID: "sensitive-uuid", + HardwareSerial: "sensitive-serial", + PrimaryMac: "sensitive-mac", + TeamName: ptr.String("sensitive-team"), + Platform: "ios", + MDM: fleet.MDMHostData{ + Profiles: &[]fleet.HostMDMProfile{ + {Identifier: "sensitive-profile"}, + }, + }, + } + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return h, nil + } + + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return h, nil + } + + ds.GetHostIssuesLastUpdatedFunc = func(ctx context.Context, hostID uint) (time.Time, error) { + return time.Now(), nil + } + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{ + OrgLogoURL: "http://example.com/logo.png", + }, + }, nil + } + + ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeVulnerabilities bool) error { + return nil + } + ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { + return nil, nil + } + ds.ListHostUsersFunc = func(ctx context.Context, hostID uint) ([]fleet.HostUser, error) { + return nil, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + return nil, nil + } + ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) { + return nil, nil + } + ds.ListLabelsForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Label, error) { + return nil, nil + } + ds.ListPacksForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Pack, error) { + return nil, nil + } + ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { + return nil, nil + } + ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostMaintenanceWindow, error) { + return nil, nil + } + ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { + return false, nil + } + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { + return &fleet.HostLockWipeStatus{}, nil + } + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + return nil, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return nil, nil + } + + // Inject host into context + ctx = host.NewContext(ctx, h) + // Inject authz context with URL-based auth method (scrubbing only happens for URL auth) + authzCtx := &authz.AuthorizationContext{} + authzCtx.SetAuthnMethod(authz.AuthnDeviceURL) + ctx = authz.NewContext(ctx, authzCtx) + + req := &getDeviceHostRequest{ + Token: "test-token", + } + + resp, err := getDeviceHostEndpoint(ctx, req, svc) + require.NoError(t, err) + + deviceResp, ok := resp.(getDeviceHostResponse) + require.True(t, ok) + require.NoError(t, deviceResp.Err) + require.NotNil(t, deviceResp.Host) + + // Verify scrubbed fields in Host + assert.Empty(t, deviceResp.Host.HardwareSerial) + assert.Empty(t, deviceResp.Host.UUID) + assert.Empty(t, deviceResp.Host.PrimaryMac) + assert.Nil(t, deviceResp.Host.TeamName) + assert.Nil(t, deviceResp.Host.MDM.Profiles) + assert.Nil(t, deviceResp.Host.Labels) + + // Verify scrubbed fields in License + assert.Empty(t, deviceResp.License.Organization) + assert.Zero(t, deviceResp.License.DeviceCount) + assert.True(t, deviceResp.License.Expiration.IsZero()) + + // Verify other fields are present + assert.Equal(t, "test-host", deviceResp.Host.Hostname) + assert.Equal(t, "http://example.com/logo.png", deviceResp.OrgLogoURL) +} + +func TestGetDeviceHostEndpointNoScrubbingForMacOS(t *testing.T) { + ds := new(mock.Store) + testLicense := &fleet.LicenseInfo{ + Tier: fleet.TierPremium, + Organization: "Test Org", + DeviceCount: 100, + Expiration: time.Now().Add(24 * time.Hour), + } + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{ + SkipCreateTestUsers: true, + License: testLicense, + }) + + h := &fleet.Host{ + ID: 1, + Hostname: "test-host-mac", + UUID: "visible-uuid", + HardwareSerial: "visible-serial", + PrimaryMac: "visible-mac", + TeamName: ptr.String("visible-team"), + Platform: "darwin", + MDM: fleet.MDMHostData{ + Profiles: &[]fleet.HostMDMProfile{ + {Identifier: "visible-profile"}, + }, + }, + } + + ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return h, nil + } + + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return h, nil + } + + ds.GetHostIssuesLastUpdatedFunc = func(ctx context.Context, hostID uint) (time.Time, error) { + return time.Now(), nil + } + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{ + OrgLogoURL: "http://example.com/logo.png", + }, + }, nil + } + + ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeVulnerabilities bool) error { + return nil + } + ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { + return nil, nil + } + ds.ListHostUsersFunc = func(ctx context.Context, hostID uint) ([]fleet.HostUser, error) { + return nil, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + return nil, nil + } + ds.GetHostMDMCheckinInfoFunc = func(ctx context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) { + return nil, nil + } + ds.ListLabelsForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Label, error) { + return nil, nil + } + ds.ListPacksForHostFunc = func(ctx context.Context, hostID uint) ([]*fleet.Pack, error) { + return nil, nil + } + ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { + return nil, nil + } + ds.ListUpcomingHostMaintenanceWindowsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostMaintenanceWindow, error) { + return nil, nil + } + ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) { + return false, nil + } + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { + return &fleet.HostLockWipeStatus{}, nil + } + ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { + return nil, nil + } + ds.ListHostDeviceMappingFunc = func(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { + return nil, nil + } + + // Inject host into context + ctx = host.NewContext(ctx, h) + // Inject authz context + authzCtx := &authz.AuthorizationContext{} + authzCtx.SetAuthnMethod(authz.AuthnDeviceToken) + ctx = authz.NewContext(ctx, authzCtx) + + req := &getDeviceHostRequest{ + Token: "test-token", + } + + resp, err := getDeviceHostEndpoint(ctx, req, svc) + require.NoError(t, err) + + deviceResp, ok := resp.(getDeviceHostResponse) + require.True(t, ok) + require.NoError(t, deviceResp.Err) + require.NotNil(t, deviceResp.Host) + + // Verify fields are NOT scrubbed + assert.Equal(t, "visible-serial", deviceResp.Host.HardwareSerial) + assert.Equal(t, "visible-uuid", deviceResp.Host.UUID) + assert.Equal(t, "visible-mac", deviceResp.Host.PrimaryMac) + assert.NotNil(t, deviceResp.Host.TeamName) + assert.Equal(t, "visible-team", *deviceResp.Host.TeamName) + assert.NotNil(t, deviceResp.Host.MDM.Profiles) + + // Verify License is NOT scrubbed (values match what we set in testLicense) + assert.Equal(t, "Test Org", deviceResp.License.Organization) + assert.Equal(t, 100, deviceResp.License.DeviceCount) + assert.False(t, deviceResp.License.Expiration.IsZero()) +} diff --git a/server/service/devices_test.go b/server/service/devices_test.go index bc9ddeac0a..edc47272b8 100644 --- a/server/service/devices_test.go +++ b/server/service/devices_test.go @@ -143,7 +143,6 @@ func TestGetFleetDesktopSummary(t *testing.T) { require.EqualValues(t, 1, *sum.FailingPolicies) assert.Equal(t, ptr.Bool(true), sum.SelfService) } - }) t.Run("different app config values for unmanaged host", func(t *testing.T) { @@ -247,7 +246,6 @@ func TestGetFleetDesktopSummary(t *testing.T) { require.Equal(t, c.out, sum.Notifications, fmt.Sprintf("enabled_and_configured: %t | macos_migration.enable: %t", c.mdm.EnabledAndConfigured, c.mdm.MacOSMigration.Enable)) require.EqualValues(t, 1, *sum.FailingPolicies) } - }) t.Run("different host attributes", func(t *testing.T) { @@ -474,7 +472,6 @@ func TestGetFleetDesktopSummary(t *testing.T) { } }) } - }) } diff --git a/server/service/devices_url_auth_test.go b/server/service/devices_url_auth_test.go new file mode 100644 index 0000000000..84fe2265ae --- /dev/null +++ b/server/service/devices_url_auth_test.go @@ -0,0 +1,195 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mock" + "github.com/go-kit/log" + "github.com/stretchr/testify/require" +) + +func TestAuthenticatedDeviceFallbackAuth(t *testing.T) { + ds := new(mock.Store) + svc, _ := newTestService(t, ds, nil, nil) + + // Mock AppConfig to avoid panic in debugEnabledForHost + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + + middleware := authenticatedDevice(svc, log.NewNopLogger(), func(ctx context.Context, request interface{}) (interface{}, error) { + return "success", nil + }) + + t.Run("success_token_auth_for_macos", func(t *testing.T) { + // macOS device with valid token - token auth succeeds first (hot path) + ds.LoadHostByDeviceAuthTokenFunc = func(ctx context.Context, authToken string, ttl time.Duration) (*fleet.Host, error) { + if authToken == "valid-device-token" { + return &fleet.Host{ + ID: 1, + UUID: "macos-device-uuid", + Platform: "darwin", + }, nil + } + return nil, newNotFoundError() + } + + req := mockDeviceAuthRequest{Token: "valid-device-token"} + _, err := middleware(context.Background(), req) + require.NoError(t, err) + }) + + t.Run("fallback_to_uuid_auth_for_ios", func(t *testing.T) { + // iOS device with UUID in URL - token auth fails, falls back to UUID auth + ds.LoadHostByDeviceAuthTokenFunc = func(ctx context.Context, authToken string, ttl time.Duration) (*fleet.Host, error) { + return nil, newNotFoundError() + } + + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + if identifier == "ios-device-uuid" { + return &fleet.Host{ + ID: 1, + UUID: "ios-device-uuid", + Platform: "ios", + }, nil + } + return nil, newNotFoundError() + } + + req := mockDeviceAuthRequest{Token: "ios-device-uuid"} + _, err := middleware(context.Background(), req) + require.NoError(t, err) + }) + + t.Run("fallback_to_uuid_auth_for_ipados", func(t *testing.T) { + // iPadOS device with UUID in URL - token auth fails, falls back to UUID auth + ds.LoadHostByDeviceAuthTokenFunc = func(ctx context.Context, authToken string, ttl time.Duration) (*fleet.Host, error) { + return nil, newNotFoundError() + } + + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + if identifier == "ipados-device-uuid" { + return &fleet.Host{ + ID: 2, + UUID: "ipados-device-uuid", + Platform: "ipados", + }, nil + } + return nil, newNotFoundError() + } + + req := mockDeviceAuthRequest{Token: "ipados-device-uuid"} + _, err := middleware(context.Background(), req) + require.NoError(t, err) + }) + + t.Run("failure_when_both_auth_methods_fail", func(t *testing.T) { + // Neither token nor UUID auth succeeds + ds.LoadHostByDeviceAuthTokenFunc = func(ctx context.Context, authToken string, ttl time.Duration) (*fleet.Host, error) { + return nil, newNotFoundError() + } + + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + return nil, newNotFoundError() + } + + req := mockDeviceAuthRequest{Token: "invalid-token"} + _, err := middleware(context.Background(), req) + require.Error(t, err) + }) +} + +type mockDeviceAuthRequest struct { + Token string +} + +func (m mockDeviceAuthRequest) deviceAuthToken() string { + return m.Token +} + +func TestAuthenticateIDeviceByURL(t *testing.T) { + ds := new(mock.Store) + svc, _ := newTestService(t, ds, nil, nil) + + // Mock AppConfig to avoid panic in debugEnabledForHost + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{}, nil + } + + t.Run("success - valid UUID for iOS device", func(t *testing.T) { + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + return &fleet.Host{ + ID: 1, + UUID: "valid-uuid", + Platform: "ios", + }, nil + } + + host, debug, err := svc.AuthenticateIDeviceByURL(context.Background(), "valid-uuid") + require.NoError(t, err) + require.False(t, debug) + require.NotNil(t, host) + require.Equal(t, uint(1), host.ID) + }) + + t.Run("success - valid UUID for iPadOS device", func(t *testing.T) { + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + return &fleet.Host{ + ID: 1, + UUID: "valid-uuid", + Platform: "ipados", + }, nil + } + + host, debug, err := svc.AuthenticateIDeviceByURL(context.Background(), "valid-uuid") + require.NoError(t, err) + require.False(t, debug) + require.NotNil(t, host) + require.Equal(t, uint(1), host.ID) + }) + + t.Run("error - missing host UUID", func(t *testing.T) { + host, debug, err := svc.AuthenticateIDeviceByURL(context.Background(), "") + require.Error(t, err) + var authReqErr *fleet.AuthRequiredError + require.ErrorAs(t, err, &authReqErr) + require.Equal(t, "authentication error: missing host UUID", authReqErr.Internal()) + require.Nil(t, host) + require.False(t, debug) + }) + + t.Run("error - host not found", func(t *testing.T) { + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + return nil, newNotFoundError() + } + + host, debug, err := svc.AuthenticateIDeviceByURL(context.Background(), "invalid-uuid") + require.Error(t, err) + var authReqErr *fleet.AuthRequiredError + require.ErrorAs(t, err, &authReqErr) + require.Contains(t, authReqErr.Internal(), "host not found") + require.Nil(t, host) + require.False(t, debug) + }) + + t.Run("error - host platform is not iOS or iPadOS (macOS)", func(t *testing.T) { + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + return &fleet.Host{ + ID: 1, + UUID: "valid-uuid", + Platform: "darwin", + }, nil + } + + host, debug, err := svc.AuthenticateIDeviceByURL(context.Background(), "valid-uuid") + require.Error(t, err) + var authReqErr *fleet.AuthRequiredError + require.ErrorAs(t, err, &authReqErr) + require.Equal(t, "authentication error: URL authentication only supported for iOS and iPadOS devices", authReqErr.Internal()) + require.Nil(t, host) + require.False(t, debug) + }) +} diff --git a/server/service/endpoint_middleware.go b/server/service/endpoint_middleware.go index c1edfd3bf8..40e2a892b4 100644 --- a/server/service/endpoint_middleware.go +++ b/server/service/endpoint_middleware.go @@ -62,6 +62,9 @@ func instrumentHostLogger(ctx context.Context, hostID uint, extras ...interface{ ) } +// authenticatedDevice checks the validity of the device auth token +// provided in the request, and attaches the corresponding host to the +// context for the request. func authenticatedDevice(svc fleet.Service, logger log.Logger, next endpoint.Endpoint) endpoint.Endpoint { authDeviceFunc := func(ctx context.Context, request interface{}) (interface{}, error) { identifier, err := getDeviceAuthToken(request) @@ -78,8 +81,16 @@ func authenticatedDevice(svc fleet.Service, logger log.Logger, next endpoint.End host, debug, err = svc.AuthenticateDeviceByCertificate(ctx, certSerial, identifier) authnMethod = authz_ctx.AuthnDeviceCertificate } else { + // Try token auth first (hot path for Fleet Desktop). host, debug, err = svc.AuthenticateDevice(ctx, identifier) - authnMethod = authz_ctx.AuthnDeviceToken + if err == nil { + authnMethod = authz_ctx.AuthnDeviceToken + } else { + // Fallback to UUID auth for iOS/iPadOS self-service via URL. + // The identifier (from {token}) is treated as the device UUID. + host, debug, err = svc.AuthenticateIDeviceByURL(ctx, identifier) + authnMethod = authz_ctx.AuthnDeviceURL + } } if err != nil { diff --git a/server/service/hosts.go b/server/service/hosts.go index ab15ac29c1..79ece81c08 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -575,7 +575,9 @@ func getHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service } func (svc *Service) GetHost(ctx context.Context, id uint, opts fleet.HostDetailOptions) (*fleet.HostDetail, error) { - alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) || svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) + alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) || + svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) || + svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) if !alreadyAuthd { // First ensure the user has access to list hosts, then check the specific // host once team_id is loaded. @@ -1113,7 +1115,9 @@ func (svc *Service) RefetchHost(ctx context.Context, id uint) error { var host *fleet.Host // iOS and iPadOS refetch are not authenticated with device token because these devices do not have Fleet Desktop, // so we don't handle that case - if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) && !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) { + if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) && + !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) && + !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) { var err error if err = svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { return err @@ -1625,7 +1629,9 @@ func listHostDeviceMappingEndpoint(ctx context.Context, request interface{}, svc } func (svc *Service) ListHostDeviceMapping(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) { - if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) && !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) { + if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) && + !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) && + !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) { if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { return nil, err } @@ -1908,7 +1914,13 @@ func getMacadminsDataEndpoint(ctx context.Context, request interface{}, svc flee } func (svc *Service) MacadminsData(ctx context.Context, id uint) (*fleet.MacadminsData, error) { - if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) && !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) { + // iOS/iPadOS devices don't have macadmins data (Munki, etc.), return nil early. + if svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) { + return nil, nil + } + + if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) && + !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) { if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { return nil, err } @@ -3112,7 +3124,9 @@ func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts flee var includeAvailableForInstall bool var host *fleet.Host - if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) && !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) { + if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) && + !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) && + !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) { includeAvailableForInstall = true if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { @@ -3229,7 +3243,9 @@ func listHostCertificatesEndpoint(ctx context.Context, request interface{}, svc } func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificatePayload, *fleet.PaginationMetadata, error) { - if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) && !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) { + if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) && + !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) && + !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) { host, err := svc.ds.HostLite(ctx, hostID) if err != nil { svc.authz.SkipAuthorization(ctx) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index a3c348bf36..d159bc091e 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -22276,7 +22276,7 @@ func generateTestCertForDeviceAuth(t *testing.T, certSerial uint64, deviceUUID s return string(certPEM), certHash, cert } -func (s *integrationEnterpriseTestSuite) TestDeviceCertificateAuthentication() { +func (s *integrationEnterpriseTestSuite) TestDeviceAuthenticationMethods() { t := s.T() ctx := context.Background() @@ -22370,8 +22370,19 @@ func (s *integrationEnterpriseTestSuite) TestDeviceCertificateAuthentication() { require.Equal(t, "ios", getHostResp.Host.Platform) }) - t.Run("iOS device without certificate header", func(t *testing.T) { - res := s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/device/%s", iosHost.UUID), nil, http.StatusUnauthorized) + t.Run("iOS device without certificate header (UUID fallback auth)", func(t *testing.T) { + // Without cert header, UUID auth is used as fallback for iOS/iPadOS devices + var getHostResp getDeviceHostResponse + res := s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/device/%s", iosHost.UUID), nil, http.StatusOK) + require.NoError(t, json.NewDecoder(res.Body).Decode(&getHostResp)) + require.NoError(t, res.Body.Close()) + require.Equal(t, iosHost.ID, getHostResp.Host.ID) + require.Equal(t, "ios", getHostResp.Host.Platform) + }) + + t.Run("iOS device with invalid UUID (no fallback)", func(t *testing.T) { + // Invalid UUID should fail both UUID auth and token auth + res := s.DoRawNoAuth("GET", "/api/latest/fleet/device/invalid-uuid-does-not-exist", nil, http.StatusUnauthorized) res.Body.Close() }) @@ -22505,6 +22516,14 @@ func (s *integrationEnterpriseTestSuite) TestDeviceCertificateAuthentication() { res.Body.Close() }) + t.Run("macOS device UUID in URL should be rejected (not iOS/iPadOS)", func(t *testing.T) { + // Using macOS host UUID directly in URL should fail: + // - Token auth fails (macHost.UUID is not a valid token) + // - UUID auth fails (platform is darwin, not iOS/iPadOS) + res := s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/device/%s", macHost.UUID), nil, http.StatusUnauthorized) + res.Body.Close() + }) + t.Run("iOS device with token auth should be rejected", func(t *testing.T) { // Create a device token for the iOS host iosToken := "ios-device-token" diff --git a/server/service/integration_vpp_install_test.go b/server/service/integration_vpp_install_test.go index 3a5a09e849..cbc535ef94 100644 --- a/server/service/integration_vpp_install_test.go +++ b/server/service/integration_vpp_install_test.go @@ -950,14 +950,17 @@ func (s *integrationMDMTestSuite) TestVPPAppInstallVerification() { } s.addHostIdentityCertificate(data.host.UUID, data.certSerial) - // self-install with no authentication - s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", data.host.UUID, 999), nil, http.StatusUnauthorized) - - // self-install a non-existing title - res := s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", data.host.UUID, 999), nil, http.StatusBadRequest, headers) + // self-install without cert header (UUID auth fallback for iOS/iPadOS) + // With fallback auth, UUID auth succeeds for iOS/iPadOS devices, so we get 400 (bad title) instead of 401 + res := s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", data.host.UUID, 999), nil, http.StatusBadRequest) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Software title is not available for install.") + // self-install a non-existing title (with cert header - same result) + res = s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", data.host.UUID, 999), nil, http.StatusBadRequest, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Software title is not available for install.") + // self-install an existing title not available for self-install res = s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", data.host.UUID, data.titleID), nil, http.StatusBadRequest, headers) errMsg = extractServerErrorText(res.Body) @@ -1592,14 +1595,17 @@ func (s *integrationMDMTestSuite) TestInHouseAppSelfInstall() { } s.addHostIdentityCertificate(iosHost.UUID, certSerial) - // self-install with no authentication - s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, 999), nil, http.StatusUnauthorized) - - // self-install a non-existing title - res := s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, 999), nil, http.StatusBadRequest, headers) + // self-install without cert header (UUID auth fallback for iOS) + // With fallback auth, UUID auth succeeds for iOS devices, so we get 400 (bad title) instead of 401 + res := s.DoRawNoAuth("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, 999), nil, http.StatusBadRequest) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Software title is not available for install.") + // self-install a non-existing title (with cert header - same result) + res = s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, 999), nil, http.StatusBadRequest, headers) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Software title is not available for install.") + // self-install an existing title not available for self-install res = s.DoRawWithHeaders("POST", fmt.Sprintf("/api/v1/fleet/device/%s/software/install/%d", iosHost.UUID, titleID), nil, http.StatusBadRequest, headers) errMsg = extractServerErrorText(res.Body) diff --git a/server/service/mdm.go b/server/service/mdm.go index cac5825f13..7aaf33f33d 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -708,7 +708,9 @@ func getMDMCommandResultsEndpoint(ctx context.Context, request interface{}, svc } func (svc *Service) GetMDMCommandResults(ctx context.Context, commandUUID string) ([]*fleet.MDMCommandResult, error) { - if svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) || svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) { + if svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) || + svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) || + svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) { return svc.getDeviceSoftwareMDMCommandResults(ctx, commandUUID) } diff --git a/server/service/service_appconfig.go b/server/service/service_appconfig.go index 2a573d0592..f437f0c539 100644 --- a/server/service/service_appconfig.go +++ b/server/service/service_appconfig.go @@ -75,7 +75,9 @@ func cleanupURL(url string) string { } func (svc *Service) License(ctx context.Context) (*fleet.LicenseInfo, error) { - if !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) && !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) { + if !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceToken) && + !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceCertificate) && + !svc.authz.IsAuthenticatedWith(ctx, authz_ctx.AuthnDeviceURL) { if err := svc.authz.Authorize(ctx, &fleet.AppConfig{}, fleet.ActionRead); err != nil { return nil, err } diff --git a/server/service/software_installers.go b/server/service/software_installers.go index ffb07556a5..1778980297 100644 --- a/server/service/software_installers.go +++ b/server/service/software_installers.go @@ -864,7 +864,9 @@ func submitDeviceSoftwareUninstall(ctx context.Context, request interface{}, svc } func (svc *Service) HasSelfServiceSoftwareInstallers(ctx context.Context, host *fleet.Host) (bool, error) { - alreadyAuthenticated := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) || svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) + alreadyAuthenticated := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) || + svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) || + svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) if !alreadyAuthenticated { if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil { return false, err