fleet/server/mdm/apple/apple_apps/api.go
Ian Littman 5c11a9feb7
Expose VPP metadata bearer token as public config, interact directly with Apple when set (#38817)
Resolves #38622.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)

## Testing

- [x] Added/updated automated tests

- [ ] QA'd all new/changed functionality manually

## New Fleet configuration settings

- [x] Setting(s) is/are explicitly excluded from GitOps
2026-01-27 16:50:40 -06:00

378 lines
11 KiB
Go

package apple_apps
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/pkg/retry"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
)
type Metadata struct {
ID string `json:"id"`
Attributes Attributes `json:"attributes"`
}
type Attributes struct {
Name string `json:"name"`
Platforms map[string]PlatformData `json:"platformAttributes"`
DeviceFamilies []string `json:"deviceFamilies"`
}
type PlatformData struct {
Artwork ArtData `json:"artwork"`
BundleID string `json:"bundleId"`
ExternalVersionID uint `json:"externalVersionId"`
LatestVersionInfo LatestVersionInfo
}
func (d PlatformData) IconURL() string {
// using set values rather than artwork response values for consistency with previous impl
return strings.ReplaceAll(
strings.ReplaceAll(
strings.ReplaceAll(d.Artwork.TemplateURL, "{w}", "512"),
"{h}",
"512",
),
"{f}",
"png",
)
}
type LatestVersionInfo struct {
DisplayVersion string `json:"versionDisplay"`
}
type ArtData struct {
Height uint `json:"height"`
Width uint `json:"width"`
TemplateURL string `json:"url"`
}
type metadataResp struct {
Data []Metadata `json:"data"`
}
const appleHostAndScheme = "https://api.ent.apple.com"
// authenticator returns a bearer token for the VPP metadata service (proxied or direct), or an error if once can't be
// retrieved. If forceRenew is true, bypasses the database bearer token cache if it would've otherwise been used.
type authenticator func(forceRenew bool) (string, error)
type Config struct {
baseURL string
authenticator authenticator
}
func StubbedConfig() Config {
return Config{
baseURL: getBaseURL(false),
authenticator: func(forceRenew bool) (string, error) { return "", nil },
}
}
// 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))
func GetMetadata(adamIDs []string, vppToken string, config Config) (map[string]Metadata, error) {
req, err := buildMetadataRequest(config.baseURL, adamIDs, vppToken)
if err != nil {
return nil, err
}
// small max attempts count because in many cases we're calling this from a UI that does
// client-side retries on top of this
var bodyResp metadataResp
if err = retry.Do(
func() error { return do(req, config.authenticator, false, &bodyResp) },
retry.WithInterval(time.Second),
retry.WithBackoffMultiplier(2),
retry.WithMaxAttempts(3),
retry.WithErrorFilter(func(err error) retry.ErrorOutcome {
// auth retries are handles inside do(); if we get all the way to the outer error,
// we've already tried to recover and should bail
if strings.Contains(err.Error(), "auth") {
return retry.ErrorOutcomeDoNotRetry
}
return retry.ErrorOutcomeNormalRetry
}),
); err != nil {
return nil, fmt.Errorf("retrieving asset metadata: %w", err)
}
metadata := make(map[string]Metadata)
for _, a := range bodyResp.Data {
metadata[fmt.Sprint(a.ID)] = a
}
return metadata, nil
}
func buildMetadataRequest(baseURL string, adamIDs []string, vppToken string) (*http.Request, error) {
reqURL, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("parsing base VPP app details URL: %w", err)
}
query := reqURL.Query()
query.Add("ids", strings.Join(adamIDs, ","))
reqURL.RawQuery = query.Encode()
req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("creating request to VPP app details endpoint: %w", err)
}
if strings.HasPrefix(baseURL, appleHostAndScheme) { // Apple requires providing the token as a cookie
req.Header.Set("Cookie", fmt.Sprintf("itvt=%s", vppToken))
} else { // Fleet proxy requires providing the token as a header
req.Header.Set("vpp-token", vppToken)
}
return req, nil
}
func do(req *http.Request, getBearerToken authenticator, forceRenew bool, dest *metadataResp) error {
bearerToken, err := getBearerToken(forceRenew)
if err != nil {
return fmt.Errorf("authenticating to VPP app details endpoint: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", bearerToken))
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("making request to VPP app details endpoint: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading response body from VPP app details endpoint: %w", err)
}
if resp.StatusCode != http.StatusOK {
limitedBody := body
if len(limitedBody) > 1000 {
limitedBody = limitedBody[:1000]
}
if resp.StatusCode == http.StatusUnauthorized && !forceRenew {
return do(req, getBearerToken, true, dest)
} else if resp.StatusCode >= http.StatusTooManyRequests && resp.Header.Get("Retry-After") != "" {
retryAfter := resp.Header.Get("Retry-After")
seconds, err := strconv.ParseInt(retryAfter, 10, 0)
if err != nil {
return fmt.Errorf("parsing retry-after header: %w", err)
}
ticker := time.NewTicker(time.Duration(seconds) * time.Second)
defer ticker.Stop()
<-ticker.C
return do(req, getBearerToken, false, dest)
}
return fmt.Errorf("calling VPP app details 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 VPP app details endpoint: %w", err)
}
}
return nil
}
func ToVPPApps(app Metadata) map[fleet.InstallableDevicePlatform]fleet.VPPApp {
// length 1 because watchOS/tvOS/visionOS exist and we don't support them, so using the length of the DeviceFamilies
// slice would give us extra empty entries
platforms := make(map[fleet.InstallableDevicePlatform]fleet.VPPApp, 1)
for _, device := range app.Attributes.DeviceFamilies {
var (
data PlatformData
ok bool
platform fleet.InstallableDevicePlatform
)
// It is rare that a single app supports all platforms, but it is possible.
// Skipping the "appletvos" platform right now as we don't support tvOS;
// see https://github.com/DIYgod/RSSHub/blob/master/lib/routes/apple/apps.ts for mapping info
switch device {
case "iphone":
data, ok = app.Attributes.Platforms["ios"]
if !ok {
continue
}
platform = fleet.IOSPlatform
case "ipad":
data, ok = app.Attributes.Platforms["ios"]
if !ok {
continue
}
platform = fleet.IPadOSPlatform
case "mac":
data, ok = app.Attributes.Platforms["osx"]
if !ok {
continue
}
platform = fleet.MacOSPlatform
default:
continue
}
platforms[platform] = fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: app.ID,
Platform: platform,
},
},
BundleIdentifier: data.BundleID,
IconURL: data.IconURL(),
Name: app.Attributes.Name,
// The "Meta Horizon" app was at some point returned by Apple with `"versionDisplay":" 353.0"`,
// so we trim the spaces here.
LatestVersion: strings.TrimSpace(data.LatestVersionInfo.DisplayVersion),
}
}
return platforms
}
func getBaseURL(bearerTokenSupplied bool) string {
region := "us"
if dev_mode.Env("FLEET_DEV_VPP_REGION") != "" {
region = dev_mode.Env("FLEET_DEV_VPP_REGION")
}
urlFromEnvVar := dev_mode.Env("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL")
if urlFromEnvVar != "" && urlFromEnvVar != "apple" {
return dev_mode.Env("FLEET_DEV_STOKEN_AUTHENTICATED_APPS_URL")
}
// if a bearer token is supplied and we don't have an explicit further override, use Apple's endpoint directly
if urlFromEnvVar == "apple" || bearerTokenSupplied {
return fmt.Sprintf(appleHostAndScheme+"/v1/catalog/%s/stoken-authenticated-apps?platform=iphone&additionalPlatforms=ipad,mac&extend[apps]=latestVersionInfo", region)
}
return fmt.Sprintf("https://fleetdm.com/api/vpp/v1/metadata/%s?platform=iphone&additionalPlatforms=ipad,mac&extend[apps]=latestVersionInfo", region)
}
type authResp struct {
Token string `json:"fleetServerSecret"`
}
type DataStore interface {
fleet.GetsAppConfig
fleet.AccessesMDMConfigAssets
}
func Configure(ctx context.Context, ds DataStore, licenseKey string, token string) Config {
if token != "" {
return Config{
authenticator: func(forceRenew bool) (string, error) { return token, nil },
baseURL: getBaseURL(true),
}
}
return Config{
authenticator: getAuthenticator(ctx, ds, licenseKey),
baseURL: getBaseURL(false),
}
}
func getAuthenticator(ctx context.Context, ds DataStore, licenseKey string) authenticator {
return func(forceRenew bool) (string, error) {
const key = fleet.MDMAssetVPPProxyBearerToken
if !forceRenew {
// throwing away the error here as on retrieval errors we'll request a new token
fromDB, _ := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{key}, nil)
if v, ok := fromDB[key]; ok {
return string(v.Value), nil
}
}
authUrl := dev_mode.Env("FLEET_DEV_VPP_PROXY_AUTH_URL")
if authUrl == "" {
authUrl = "https://fleetdm.com/api/vpp/v1/auth"
}
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting server URL from app config")
}
body, err := json.Marshal(struct {
ServerURL string `json:"fleetServerUrl"`
LicenseKey string `json:"fleetLicenseKey"`
}{appConfig.ServerSettings.ServerURL, licenseKey})
if err != nil {
return "", ctxerr.Wrap(ctx, err, "encoding authentication request for VPP metadata service")
}
req, err := http.NewRequestWithContext(ctx, "POST", authUrl, bytes.NewBuffer(body))
if err != nil {
return "", ctxerr.Wrap(ctx, err, "building authentication request for VPP metadata service")
}
var authResponse authResp
if err = doAuth(req, &authResponse); err != nil {
return "", ctxerr.Wrap(ctx, err, "authenticating to VPP metadata service")
}
if authResponse.Token == "" {
return "", ctxerr.New(ctx, "no access token received from VPP metadata service")
}
// no need to keep old access tokens around, but no need to hard-fail if we can't clean them up
_ = ds.HardDeleteMDMConfigAsset(ctx, key)
// don't fail if we can't persist the token; we can continue anyway and will try again with the next request
_ = ds.InsertOrReplaceMDMConfigAsset(ctx, fleet.MDMConfigAsset{Name: key, Value: []byte(authResponse.Token)})
return authResponse.Token, nil
}
}
func doAuth(req *http.Request, dest *authResp) error {
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("authenticating to VPP metadata service: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading authentication response from VPP metadata service: %w", err)
}
if resp.StatusCode != http.StatusOK {
limitedBody := body
if len(limitedBody) > 1000 {
limitedBody = limitedBody[:1000]
}
return fmt.Errorf("calling authentication endpoint for VPP metadata service 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 authentication endpoint for VPP metdata service: %w", err)
}
}
return nil
}