Website: Add VPP metadata proxy (#37997)

For https://github.com/fleetdm/fleet/issues/37261

Changes:
- Added a new database model: `FleetInstanceUsingVpp`
- Added `/api/vpp/v1/register`: An API endpoint that validates provided
Fleet license keys, creates a database record for the proxy
registration, and returns a generated secret used to authenticate
requests to the other VPP proxy endpoint
- Added `/api/vpp/v1/metadata/:storeRegion`: An API endpoint that
forwards requests to the
`https://api.ent.apple.com/v1/catalog/${storeRegion}/stoken-authenticated-apps`
Apple API with a token generated using Fleet's Apple developer
credentials.

---------

Co-authored-by: Ian Littman <iansltx@gmail.com>
This commit is contained in:
Eric 2026-01-08 11:29:53 -06:00 committed by GitHub
parent 4ab5874e25
commit b3bd4686a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 262 additions and 1 deletions

1
website/.eslintrc vendored
View file

@ -54,6 +54,7 @@
"MicrosoftComplianceTenant": true,
"AndroidEnterprise": true,
"RenderProofOfValue": true,
"FleetInstanceUsingVpp": true
// …and any others.
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
},

View file

@ -0,0 +1,125 @@
module.exports = {
friendlyName: 'Get vpp app metadata',
description: 'Proxies authenticated requests from Fleet instances to the Apple App store API.',
inputs: {
storeRegion: {
type: 'string',
required: true,
description: 'The App store region that a proxied request will be sent to.',
},
platform: {
type: 'string',
description: 'The platform the specified app(s) runs on',
},
additionalPlatforms: {
type: 'string',
description: 'A comma separated list of platforms that are included in the proxied request.'
},
ids: {
type: 'string',
description: 'A comma separated list of IDs of app store apps to include in the response.'
},
extend: {
type: {},
description: 'An object containing the name of additional attributes to include in the API response.'
}
},
exits: {
success: {
description: 'App metadata was sent to the Fleet server',
outputType: {},
},
missingAuthHeader: {
description: 'This request is missing an authorization header.',
responseType: 'unauthorized'
},
invalidFleetServerSecret: {
description: 'Invalid authentication token.',
responseType: 'unauthorized',
},
missingVppToken: {
description: 'This request is missing a VPP app token',
responseType: 'badRequest',
},
},
fn: async function ({storeRegion, ids, platform, additionalPlatforms, extend}) {
// Validate the provided fleetServerSecret
let authHeader = this.req.get('authorization');
let fleetServerSecret;
if (authHeader && authHeader.startsWith('Bearer')) {
fleetServerSecret = authHeader.replace('Bearer', '').trim();
} else {
throw 'missingAuthHeader';
}
let thisFleetInstance = await FleetInstanceUsingVpp.findOne({
fleetServerSecret: fleetServerSecret
});
if(!thisFleetInstance) {
throw 'invalidFleetServerSecret';
}
let cookieHeader = this.req.get('cookie');
if(!cookieHeader) {
// If no cookie header was included return a missingVppToken (badRequest) response to the Fleet instance.
throw 'missingVppToken';
}
let nowAt = Date.now();
let nowAtInSeconds = Math.floor(nowAt / 1000);
let expiresAtInSeconds = nowAtInSeconds + 60;
let tokenForThisRequest = require('jsonwebtoken').sign(
{
iss: sails.config.custom.vppProxyTokenTeamId,
exp: expiresAtInSeconds,
iat: nowAtInSeconds,
},
sails.config.custom.vppProxyTokenPrivateKey,
{
algorithm: 'ES256',
keyid: sails.config.custom.vppProxyTokenKeyId,
}
);
let responseFromAppleApi = await sails.helpers.http.get.with({
url: `https://api.ent.apple.com/v1/catalog/${storeRegion}/stoken-authenticated-apps`,
data: {
ids,
platform,
additionalPlatforms,
extend,
},
headers: {
'Authorization': `Bearer ${tokenForThisRequest}`,
'Cookie': `${cookieHeader}`,
}
})
.tolerate((err)=>{
sails.log.warn(`When a Fleet instance sent a proxied request to the Apple App Store API, an error occured. Full error: ${require('util').inspect(err)}`);
return err;
});
return responseFromAppleApi;
}
};

View file

@ -0,0 +1,83 @@
module.exports = {
friendlyName: 'Register one Fleet instance for using VPP',
description: 'Creates a registration for a Fleet instance in the website\'s database and returns a generated secret to authenticate future requests.',
inputs: {
fleetServerUrl: {
type: 'string',
required: true,
},
},
exits: {
success: {
description: 'A Fleet instance\'s VPP proxy registration was successfully submitted.',
outputType: {
fleetServerSecret: 'string',
},
},
couldNotVerifyLicense: {
description: 'The Fleet license key could not be verified.',
responseType: 'unauthorized',
},
invalidFleetServerUrl: {
description: 'The provided Fleet server URL does not appear to be a URL.',
responseType: 'badRequest',
},
},
fn: async function ({fleetServerUrl}) {
// Get the Fleet license key that was sent in the Authorization header as a bearer token.
let authHeader = this.req.get('authorization');
let fleetLicenseKey;
if (authHeader && authHeader.startsWith('Bearer')) {
fleetLicenseKey = authHeader.replace('Bearer', '').trim();
} else {
throw 'couldNotVerifyLicense';
}
// Validate provided fleetLicenseKey
try {
require('jsonwebtoken').verify(
fleetLicenseKey,
sails.config.custom.licenseKeyGeneratorPublicKey,
{ algorithm: 'ES256' }
);
} catch(unusedErr) {
// If there is an error parsing the provided fleetLicenseKey, return a couldNotVerifyLicense response.
throw 'couldNotVerifyLicense';
}
// validate Fleet server URL
try {
new URL(fleetServerUrl);
} catch(unusedErr) {
throw 'invalidFleetServerUrl';
}
// Generate a new FleetServerSecret for this Fleet instance.
let fleetServerSecret = sails.helpers.strings.random.with({len: 30});
// Create a new database record for this Fleet instance.
await FleetInstanceUsingVpp.create({fleetInstanceUrl: fleetServerUrl, fleetServerSecret});
// Return the generated fleetServerSecret
return {
fleetServerSecret,
};
}
};

View file

@ -0,0 +1,39 @@
/**
* InstanceUsingVpp.js// TODO: better name.
*
* @description :: A model definition represents a database table/collection.
* @docs :: https://sailsjs.com/docs/concepts/models-and-orm/models
*/
module.exports = {
attributes: {
// ╔═╗╦═╗╦╔╦╗╦╔╦╗╦╦ ╦╔═╗╔═╗
// ╠═╝╠╦╝║║║║║ ║ ║╚╗╔╝║╣ ╚═╗
// ╩ ╩╚═╩╩ ╩╩ ╩ ╩ ╚╝ ╚═╝╚═╝
fleetInstanceUrl: {
type: 'string',
required: true,
description: 'The URL of a Fleet instance that is using the fleet website VPP proxy.'
},
fleetServerSecret: {
type: 'string',
required: true,
description: 'A generated secret used to autheticate & identify requests from a particular Fleet instance.'
},
// ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗
// ║╣ ║║║╠╩╗║╣ ║║╚═╗
// ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝
// ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗
// ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝
},
};

View file

@ -67,7 +67,8 @@
"Platform": false,
"AdCampaign": false,
"MicrosoftComplianceTenant": false,
"AndroidEnterprise": false
"AndroidEnterprise": false,
"FleetInstanceUsingVpp": false
// ...and any other backend globals (e.g. `"Organization": false`)
// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
}

View file

@ -488,5 +488,9 @@ module.exports.custom = {
// androidEnterpriseServiceAccountEmailAddress: '…',
// androidEnterpriseServiceAccountPrivateKey: '…',
// VPP proxy
// vppProxyTokenTeamId: '',
// vppProxyTokenKeyId: '',
// vppProxyTokenPrivateKey: '',
};

View file

@ -82,4 +82,5 @@ module.exports.policies = {
'microsoft-proxy/view-turn-on-mdm': true,
'view-okta-conditional-access-error': true,
'view-fast-track': true,
'vpp-proxy/*': true,
};

View file

@ -1272,6 +1272,13 @@ module.exports.routes = {
'GET /api/v1/microsoft-compliance-partner/device/message': { action: 'microsoft-proxy/get-one-compliance-status-result', },
'GET /api/v1/microsoft-compliance-partner/adminconsent': { action: 'microsoft-proxy/receive-redirect-from-microsoft', },
//
// ╦ ╦╔═╗╔═╗ ╔╦╗╔═╗╔╦╗╔═╗╔╦╗╔═╗╔╦╗╔═╗ ╔═╗╦═╗╔═╗═╗ ╦╦ ╦ ╔═╗╔╗╔╔╦╗╔═╗╔═╗╦╔╗╔╔╦╗╔═╗
// ╚╗╔╝╠═╝╠═╝ ║║║║╣ ║ ╠═╣ ║║╠═╣ ║ ╠═╣ ╠═╝╠╦╝║ ║╔╩╦╝╚╦╝ ║╣ ║║║ ║║╠═╝║ ║║║║║ ║ ╚═╗
// ╚╝ ╩ ╩ ╩ ╩╚═╝ ╩ ╩ ╩═╩╝╩ ╩ ╩ ╩ ╩ ╩ ╩╚═╚═╝╩ ╚═ ╩ ╚═╝╝╚╝═╩╝╩ ╚═╝╩╝╚╝ ╩ ╚═╝
'POST /api/vpp/v1/register': { action: 'vpp-proxy/register-one-fleet-instance-using-vpp', csrf: false },
'GET /api/vpp/v1/metadata/:storeRegion': { action: 'vpp-proxy/get-vpp-app-metadata' },
// Well known resources https://datatracker.ietf.org/doc/html/rfc8615
// =============================================================================================================
//