mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
add a client for VPP calls (#20243)
Co-authored-by: Martin Angers <martin.n.angers@gmail.com>
This commit is contained in:
parent
d9797d2a3f
commit
e9dba549ca
4 changed files with 501 additions and 64 deletions
244
server/mdm/apple/vpp/api.go
Normal file
244
server/mdm/apple/vpp/api.go
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
244
server/mdm/apple/vpp/api_test.go
Normal file
244
server/mdm/apple/vpp/api_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
|||
Loading…
Reference in a new issue