Add new self-service auth method for iOS/iPadOS (#36659)

Implements #36542. Adds URL/UDID-based authentication for the My Device page on iOS/iPadOS.
This commit is contained in:
Carlo
2025-12-05 10:16:46 -05:00
committed by GitHub
parent 4dc23ed1f3
commit 4cd91333c5
17 changed files with 617 additions and 33 deletions
+6 -2
View File
@@ -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)
}
+3 -1
View File
@@ -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)
+3
View File
@@ -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
+4
View File
@@ -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.
+12
View File
@@ -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
+3 -1
View File
@@ -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
}
+58 -3
View File
@@ -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"))
}
+252
View File
@@ -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())
}
-3
View File
@@ -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) {
}
})
}
})
}
+195
View File
@@ -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)
})
}
+12 -1
View File
@@ -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 {
+22 -6
View File
@@ -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)
+22 -3
View File
@@ -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"
+16 -10
View File
@@ -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)
+3 -1
View File
@@ -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)
}
+3 -1
View File
@@ -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
}
+3 -1
View File
@@ -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