diff --git a/server/mdm/apple/vpp/api.go b/server/mdm/apple/vpp/api.go new file mode 100644 index 0000000000..a0bd17f655 --- /dev/null +++ b/server/mdm/apple/vpp/api.go @@ -0,0 +1,244 @@ +package vpp + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "time" + + "github.com/fleetdm/fleet/v4/pkg/fleethttp" +) + +// Asset is a product in the store. +// +// https://developer.apple.com/documentation/devicemanagement/asset +type Asset struct { + // AdamID is the unique identifier for a product in the store. + AdamID string `json:"adamId"` + // PricingParam is the quality of a product in the store. + // Possible Values are `STDQ` and `PLUS` + PricingParam string `json:"pricingParam"` +} + +// ErrorResponse represents the response that contains the error that occurs. +// +// https://developer.apple.com/documentation/devicemanagement/errorresponse +type ErrorResponse struct { + ErrorInfo ResponseErrorInfo `json:"errorInfo"` + ErrorMessage string `json:"errorMessage"` + ErrorNumber int32 `json:"errorNumber"` +} + +// Error implements the Erorrer interface +func (e *ErrorResponse) Error() string { + return fmt.Sprintf("Apple VPP endpoint returned error: %s (error number: %d)", e.ErrorMessage, e.ErrorNumber) +} + +// ResponseErrorInfo represents the request-specific information regarding the +// failure. +// +// https://developer.apple.com/documentation/devicemanagement/responseerrorinfo +type ResponseErrorInfo struct { + Assets []Asset `json:"assets"` + ClientUserIds []string `json:"clientUserIds"` + SerialNumbers []string `json:"serialNumbers"` +} + +// client is a package-level client (similar to http.DefaultClient) so it can +// be reused instead of created as needed, as the internal Transport typically +// has internal state (cached connections, etc) and it's safe for concurrent +// use. +var client = fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)) + +// GetConfig fetches the VPP config from Apple's VPP API. This doubles as a +// verification that the user-provided VPP token is valid. +// +// https://developer.apple.com/documentation/devicemanagement/client_config-a40 +func GetConfig(token string) (string, error) { + req, err := http.NewRequest(http.MethodGet, getBaseURL()+"/client/config", nil) + if err != nil { + return "", fmt.Errorf("creating request to Apple VPP endpoint: %w", err) + } + + var respJSON struct { + LocationName string `json:"locationName"` + } + + if err := do(req, token, &respJSON); err != nil { + return "", fmt.Errorf("making request to Apple VPP endpoint: %w", err) + } + + return respJSON.LocationName, nil +} + +// AssociateAssetsRequest is the request for asset management. +type AssociateAssetsRequest struct { + // Assets are the assets to assign. + Assets []Asset `json:"assets"` + // SerialNumbers is the set of identifiers for devices to assign the + // assets to. + SerialNumbers []string `json:"serialNumbers"` +} + +// AssociateAssets associates assets to serial numbers according the the +// request parameters provided. +// +// https://developer.apple.com/documentation/devicemanagement/associate_assets +func AssociateAssets(token string, params *AssociateAssetsRequest) error { + var reqBody bytes.Buffer + if err := json.NewEncoder(&reqBody).Encode(params); err != nil { + return fmt.Errorf("encoding params as JSON: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, getBaseURL()+"/assets/associate", &reqBody) + if err != nil { + return fmt.Errorf("creating request to Apple VPP endpoint: %w", err) + } + + if err := do[any](req, token, nil); err != nil { + return fmt.Errorf("making request to Apple VPP endpoint: %w", err) + } + return nil +} + +// AssetFilter represents the filters for querying assets. +type AssetFilter struct { + // PageIndex is the requested page index. + PageIndex int32 `json:"pageIndex"` + + // ProductType is the filter for the asset product type. + // Possible Values: App, Book + ProductType string `json:"productType"` + + // PricingParam is the filter for the asset product quality. + // Possible Values: STDQ, PLUS + PricingParam string `json:"pricingParam"` + + // Revocable is the filter for asset revocability. + Revocable *bool `json:"revocable"` + + // DeviceAssignable is the filter for asset device assignability. + DeviceAssignable *bool `json:"deviceAssignable"` + + // MaxAvailableCount is the filter for the maximum inclusive assets available count. + MaxAvailableCount int32 `json:"maxAvailableCount"` + + // MinAvailableCount is the filter for the minimum inclusive assets available count. + MinAvailableCount int32 `json:"minAvailableCount"` + + // MaxAssignedCount is the filter for the maximum inclusive assets assigned count. + MaxAssignedCount int32 `json:"maxAssignedCount"` + + // MinAssignedCount is the filter for the minimum inclusive assets assigned count. + MinAssignedCount int32 `json:"minAssignedCount"` + + // AdamID is the filter for the asset product unique identifier. + AdamID string `json:"adamId"` +} + +// GetAssets fetches the assets from Apple's VPP API with optional filters. +func GetAssets(token string, filter *AssetFilter) ([]Asset, error) { + baseURL := getBaseURL() + "/assets" + reqURL, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("parsing base URL: %w", err) + } + + if filter != nil { + query := url.Values{} + addFilter(query, "adamId", filter.AdamID) + addFilter(query, "pricingParam", filter.PricingParam) + addFilter(query, "productType", filter.ProductType) + addFilter(query, "revocable", filter.Revocable) + addFilter(query, "deviceAssignable", filter.DeviceAssignable) + addFilter(query, "maxAvailableCount", filter.MaxAvailableCount) + addFilter(query, "minAvailableCount", filter.MinAvailableCount) + addFilter(query, "maxAssignedCount", filter.MaxAssignedCount) + addFilter(query, "minAssignedCount", filter.MinAssignedCount) + addFilter(query, "pageIndex", filter.PageIndex) + reqURL.RawQuery = query.Encode() + } + + req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("creating request to Apple VPP endpoint: %w", err) + } + + var bodyResp struct { + Assets []Asset `json:"assets"` + } + + if err = do(req, token, &bodyResp); err != nil { + return nil, fmt.Errorf("retrieving assets: %w", err) + } + + return bodyResp.Assets, nil +} + +func do[T any](req *http.Request, token string, dest *T) error { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("making request to Apple VPP endpoint: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response body from Apple VPP endpoint: %w", err) + } + + // For some reason, Apple returns 200 OK even if you pass an invalid token in the Auth header. + // We will need to parse the response and check to see if it contains an error. + var errResp ErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil && (errResp.ErrorMessage != "" || errResp.ErrorNumber != 0) { + return &errResp + } + + if resp.StatusCode != http.StatusOK { + limitedBody := body + if len(limitedBody) > 1000 { + limitedBody = limitedBody[:1000] + } + return fmt.Errorf("calling Apple VPP endpoint failed with status %d: %s", resp.StatusCode, string(limitedBody)) + } + + if dest != nil { + if err := json.Unmarshal(body, dest); err != nil { + return fmt.Errorf("decoding response data from Apple VPP endpoint: %w", err) + } + } + + return nil +} + +func getBaseURL() string { + devURL := os.Getenv("FLEET_DEV_VPP_URL") + if devURL != "" { + return devURL + } + return "https://vpp.itunes.apple.com/mdm/v2" +} + +// addFilter adds a filter to the query values if it is not the zero value. +func addFilter(query url.Values, key string, value any) { + switch v := value.(type) { + case string: + if v != "" { + query.Add(key, v) + } + case *bool: + if v != nil { + query.Add(key, strconv.FormatBool(*v)) + } + case int32: + if v != 0 { + query.Add(key, fmt.Sprintf("%d", v)) + } + } +} diff --git a/server/mdm/apple/vpp/api_test.go b/server/mdm/apple/vpp/api_test.go new file mode 100644 index 0000000000..5421826dfd --- /dev/null +++ b/server/mdm/apple/vpp/api_test.go @@ -0,0 +1,244 @@ +package vpp + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func setupFakeServer(t *testing.T, handler http.HandlerFunc) { + server := httptest.NewServer(handler) + os.Setenv("FLEET_DEV_VPP_URL", server.URL) + t.Cleanup(server.Close) +} + +func TestGetConfig(t *testing.T) { + tests := []struct { + name string + token string + handler http.HandlerFunc + wantName string + expectedErrMsg string + }{ + { + name: "valid token", + token: "valid_token", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, `{"locationName": "Test Location"}`) + }, + wantName: "Test Location", + expectedErrMsg: "", + }, + { + name: "invalid token", + token: "invalid_token", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprintln(w, `{"errorNumber": 9622}`) + }, + wantName: "", + expectedErrMsg: "making request to Apple VPP endpoint: Apple VPP endpoint returned error: (error number: 9622)", + }, + { + name: "server error", + token: "valid_token", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, `Internal Server Error`) + }, + wantName: "", + expectedErrMsg: "calling Apple VPP endpoint failed with status 500: Internal Server Error\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupFakeServer(t, tt.handler) + + name, err := GetConfig(tt.token) + if tt.expectedErrMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErrMsg) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantName, name) + }) + } +} + +func TestAssociateAssets(t *testing.T) { + tests := []struct { + name string + token string + params *AssociateAssetsRequest + handler http.HandlerFunc + expectedErrMsg string + }{ + { + name: "valid request", + token: "valid_token", + params: &AssociateAssetsRequest{ + Assets: []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, + SerialNumbers: []string{"SN12345"}, + }, + handler: func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/assets/associate", r.URL.Path) + require.Equal(t, "Bearer valid_token", r.Header.Get("Authorization")) + + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var reqParams AssociateAssetsRequest + err = json.Unmarshal(body, &reqParams) + require.NoError(t, err) + + require.Equal(t, []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, reqParams.Assets) + require.Equal(t, []string{"SN12345"}, reqParams.SerialNumbers) + + w.WriteHeader(http.StatusOK) + }, + expectedErrMsg: "", + }, + { + name: "server error", + token: "valid_token", + params: &AssociateAssetsRequest{ + Assets: []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, + SerialNumbers: []string{"SN12345"}}, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, `Internal Server Error`) + }, + expectedErrMsg: "calling Apple VPP endpoint failed with status 500: Internal Server Error\n", + }, + { + name: "client error", + token: "valid_token", + params: &AssociateAssetsRequest{ + Assets: []Asset{{AdamID: "12345", PricingParam: "STDQ"}}, + SerialNumbers: []string{"SN12345"}}, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(w, `{"errorInfo":{},"errorMessage":"Bad Request","errorNumber":400}`) + }, + expectedErrMsg: "making request to Apple VPP endpoint: Apple VPP endpoint returned error: Bad Request (error number: 400)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupFakeServer(t, tt.handler) + + err := AssociateAssets(tt.token, tt.params) + if tt.expectedErrMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGetAssets(t *testing.T) { + tests := []struct { + name string + token string + filter *AssetFilter + handler http.HandlerFunc + expectedAssets []Asset + expectedErrMsg string + }{ + { + name: "valid token and filters", + token: "valid_token", + filter: &AssetFilter{ + AdamID: "12345", + }, + handler: func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/assets", r.URL.Path) + require.Equal(t, "Bearer valid_token", r.Header.Get("Authorization")) + + query := r.URL.Query() + require.Equal(t, "12345", query.Get("adamId")) + + type resp struct { + Assets []Asset `json:"assets"` + } + assets := resp{ + Assets: []Asset{ + {AdamID: "12345", PricingParam: "STDQ"}, + {AdamID: "67890", PricingParam: "PLUS"}, + }, + } + w.WriteHeader(http.StatusOK) + require.NoError(t, json.NewEncoder(w).Encode(assets)) + }, + expectedAssets: []Asset{ + {AdamID: "12345", PricingParam: "STDQ"}, + {AdamID: "67890", PricingParam: "PLUS"}, + }, + expectedErrMsg: "", + }, + { + name: "server error", + token: "valid_token", + filter: nil, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, `Internal Server Error`) + }, + expectedAssets: nil, + expectedErrMsg: "calling Apple VPP endpoint failed with status 500: Internal Server Error\n", + }, + { + name: "client error", + token: "valid_token", + filter: nil, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(w, `{"errorInfo":{},"errorMessage":"Bad Request","errorNumber":400}`) + }, + expectedAssets: nil, + expectedErrMsg: "retrieving assets: Apple VPP endpoint returned error: Bad Request (error number: 400)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupFakeServer(t, tt.handler) + + assets, err := GetAssets(tt.token, tt.filter) + if tt.expectedErrMsg != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErrMsg) + } else { + require.NoError(t, err) + require.Equal(t, tt.expectedAssets, assets) + } + }) + } +} + +func TestGetBaseURL(t *testing.T) { + t.Run("Default URL", func(t *testing.T) { + os.Setenv("FLEET_DEV_VPP_URL", "") + require.Equal(t, "https://vpp.itunes.apple.com/mdm/v2", getBaseURL()) + }) + + t.Run("Custom URL", func(t *testing.T) { + customURL := "http://localhost:8000" + os.Setenv("FLEET_DEV_VPP_URL", customURL) + require.Equal(t, customURL, getBaseURL()) + }) +} diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 307183e53d..b093786080 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -1054,12 +1054,12 @@ func (s *integrationMDMTestSuite) uploadDataViaForm(endpoint, fieldName, fileNam func (s *integrationMDMTestSuite) TestMDMVPPToken() { t := s.T() // Invalid token - testOverrideAppleVPPConfigURL = s.appleVPPConfigSrv.URL + "?invalidToken" + t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL+"?invalidToken") s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusUnprocessableEntity, "Invalid token. Please provide a valid content token from Apple Business Manager.") // Simulate a server error from the Apple API - testOverrideAppleVPPConfigURL = s.appleVPPConfigSrv.URL + "?serverError" - s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusInternalServerError, "calling Apple VPP config endpoint failed with status 500") + t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL+"?serverError") + s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte("foobar"), http.StatusInternalServerError, "Apple VPP endpoint returned error: Internal server error (error number: 9603)") // Valid token orgName := "Fleet Device Management Inc." @@ -1067,7 +1067,7 @@ func (s *integrationMDMTestSuite) TestMDMVPPToken() { token := "mycooltoken" expDate := "2025-06-24T15:50:50+0000" tokenJSON := fmt.Sprintf(`{"expDate":"%s","token":"%s","orgName":"%s"}`, expDate, token, orgName) - testOverrideAppleVPPConfigURL = s.appleVPPConfigSrv.URL + t.Setenv("FLEET_DEV_VPP_URL", s.appleVPPConfigSrv.URL) s.uploadDataViaForm("/api/latest/fleet/mdm/apple/vpp_token", "token", "token.vpptoken", []byte(base64.StdEncoding.EncodeToString([]byte(tokenJSON))), http.StatusAccepted, "") // Get the token diff --git a/server/service/mdm.go b/server/service/mdm.go index fdd7731f74..6472b36e8e 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -31,6 +31,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" + "github.com/fleetdm/fleet/v4/server/mdm/apple/vpp" "github.com/fleetdm/fleet/v4/server/mdm/assets" nanomdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/ptr" @@ -2600,15 +2601,18 @@ func (svc *Service) UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSee return ctxerr.Wrap(ctx, err, "reading VPP token") } - locName, tokenValid, err := getVPPConfig(string(tokenBytes)) + locName, err := vpp.GetConfig(string(tokenBytes)) if err != nil { + var vppErr *vpp.ErrorResponse + if errors.As(err, &vppErr) { + // Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes + if vppErr.ErrorNumber == 9622 { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) + } + } return ctxerr.Wrap(ctx, err, "validating VPP token with Apple") } - if !tokenValid { - return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("token", "Invalid token. Please provide a valid content token from Apple Business Manager.")) - } - decodedTokenBytes, err := base64.StdEncoding.DecodeString(string(tokenBytes)) if err != nil { return ctxerr.Wrap(ctx, err, "decoding VPP token") @@ -2634,61 +2638,6 @@ func (svc *Service) UploadMDMAppleVPPToken(ctx context.Context, token io.ReadSee return nil } -var testOverrideAppleVPPConfigURL string - -// getVPPConfig fetches the VPP config from Apple's VPP API. This doubles as a verification that the -// user-provided VPP token is valid. -func getVPPConfig(token string) (string, bool, error) { - url := "https://vpp.itunes.apple.com/mdm/v2/client/config" - if testOverrideAppleVPPConfigURL != "" { - url = testOverrideAppleVPPConfigURL - } - - bearer := fmt.Sprintf("Bearer %s", token) - - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return "", false, fmt.Errorf("creating request to Apple VPP endpoint: %w", err) - } - - req.Header.Add("Authorization", bearer) - - client := fleethttp.NewClient(fleethttp.WithTimeout(10 * time.Second)) - resp, err := client.Do(req) - if err != nil { - return "", false, fmt.Errorf("making request to Apple VPP endpoint: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", false, fmt.Errorf("reading response body from Apple VPP endpoint: %w", err) - } - - // For some reason, Apple returns 200 OK even if you pass an invalid token in the Auth header. - // We will need to parse the response and check to see if it contains an error. - - var respJSON struct { - LocationName string `json:"locationName"` - ErrorNumber int `json:"errorNumber"` - } - - if err := json.Unmarshal(body, &respJSON); err != nil { - return "", false, fmt.Errorf("parsing response body from Apple VPP endpoint: %w", err) - } - - // Per https://developer.apple.com/documentation/devicemanagement/app_and_book_management/app_and_book_management_legacy/interpreting_error_codes - if resp.StatusCode == 401 || respJSON.ErrorNumber == 9622 { - return "", false, nil - } - - if resp.StatusCode != http.StatusOK { - return "", false, fmt.Errorf("calling Apple VPP config endpoint failed with status %d", resp.StatusCode) - } - - return respJSON.LocationName, true, nil -} - //////////////////////////////////////////////////////////////////////////////// // GET /vpp ////////////////////////////////////////////////////////////////////////////////