add a client for VPP calls (#20243)

Co-authored-by: Martin Angers <martin.n.angers@gmail.com>
This commit is contained in:
Roberto Dip 2024-07-08 10:50:20 -03:00 committed by GitHub
parent d9797d2a3f
commit e9dba549ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 501 additions and 64 deletions

244
server/mdm/apple/vpp/api.go Normal file
View file

@ -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))
}
}
}

View file

@ -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())
})
}

View file

@ -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

View file

@ -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
////////////////////////////////////////////////////////////////////////////////