From b3bd4686a3c70dae66c2c6787fb2199f7f2ea7b6 Mon Sep 17 00:00:00 2001 From: Eric Date: Thu, 8 Jan 2026 11:29:53 -0600 Subject: [PATCH] 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 --- website/.eslintrc | 1 + .../vpp-proxy/get-vpp-app-metadata.js | 125 ++++++++++++++++++ .../register-one-fleet-instance-using-vpp.js | 83 ++++++++++++ website/api/models/FleetInstanceUsingVpp.js | 39 ++++++ website/assets/.eslintrc | 3 +- website/config/custom.js | 4 + website/config/policies.js | 1 + website/config/routes.js | 7 + 8 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 website/api/controllers/vpp-proxy/get-vpp-app-metadata.js create mode 100644 website/api/controllers/vpp-proxy/register-one-fleet-instance-using-vpp.js create mode 100644 website/api/models/FleetInstanceUsingVpp.js diff --git a/website/.eslintrc b/website/.eslintrc index e0eacf7b92..61a2e7ec6c 100644 --- a/website/.eslintrc +++ b/website/.eslintrc @@ -54,6 +54,7 @@ "MicrosoftComplianceTenant": true, "AndroidEnterprise": true, "RenderProofOfValue": true, + "FleetInstanceUsingVpp": true // …and any others. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - }, diff --git a/website/api/controllers/vpp-proxy/get-vpp-app-metadata.js b/website/api/controllers/vpp-proxy/get-vpp-app-metadata.js new file mode 100644 index 0000000000..7fe61421f9 --- /dev/null +++ b/website/api/controllers/vpp-proxy/get-vpp-app-metadata.js @@ -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; + + } + + +}; diff --git a/website/api/controllers/vpp-proxy/register-one-fleet-instance-using-vpp.js b/website/api/controllers/vpp-proxy/register-one-fleet-instance-using-vpp.js new file mode 100644 index 0000000000..952e513d3b --- /dev/null +++ b/website/api/controllers/vpp-proxy/register-one-fleet-instance-using-vpp.js @@ -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, + }; + + } + + +}; diff --git a/website/api/models/FleetInstanceUsingVpp.js b/website/api/models/FleetInstanceUsingVpp.js new file mode 100644 index 0000000000..ab40c2b2fc --- /dev/null +++ b/website/api/models/FleetInstanceUsingVpp.js @@ -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.' + }, + + // ╔═╗╔╦╗╔╗ ╔═╗╔╦╗╔═╗ + // ║╣ ║║║╠╩╗║╣ ║║╚═╗ + // ╚═╝╩ ╩╚═╝╚═╝═╩╝╚═╝ + + + // ╔═╗╔═╗╔═╗╔═╗╔═╗╦╔═╗╔╦╗╦╔═╗╔╗╔╔═╗ + // ╠═╣╚═╗╚═╗║ ║║ ║╠═╣ ║ ║║ ║║║║╚═╗ + // ╩ ╩╚═╝╚═╝╚═╝╚═╝╩╩ ╩ ╩ ╩╚═╝╝╚╝╚═╝ + + }, + +}; + diff --git a/website/assets/.eslintrc b/website/assets/.eslintrc index 13d0ae5122..e566eeb264 100644 --- a/website/assets/.eslintrc +++ b/website/assets/.eslintrc @@ -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`) // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - } diff --git a/website/config/custom.js b/website/config/custom.js index dbeefb35ac..70dc473207 100644 --- a/website/config/custom.js +++ b/website/config/custom.js @@ -488,5 +488,9 @@ module.exports.custom = { // androidEnterpriseServiceAccountEmailAddress: '…', // androidEnterpriseServiceAccountPrivateKey: '…', + // VPP proxy + // vppProxyTokenTeamId: '', + // vppProxyTokenKeyId: '', + // vppProxyTokenPrivateKey: '', }; diff --git a/website/config/policies.js b/website/config/policies.js index 7cf0003180..893cfdc119 100644 --- a/website/config/policies.js +++ b/website/config/policies.js @@ -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, }; diff --git a/website/config/routes.js b/website/config/routes.js index 053ca6f971..8f7f2817b5 100644 --- a/website/config/routes.js +++ b/website/config/routes.js @@ -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 // ============================================================================================================= //