UNPKG

mk9-prebid

Version:

Header Bidding Management Library

520 lines (463 loc) 21.5 kB
/** * This module adds GDPR consentManagement support to prebid.js. It interacts with * supported CMPs (Consent Management Platforms) to grab the user's consent information * and make it available for any GDPR supported adapters to read/pass this information to * their system. */ import * as utils from '../src/utils.js'; import { config } from '../src/config.js'; import { gdprDataHandler } from '../src/adapterManager.js'; import includes from 'core-js-pure/features/array/includes.js'; import strIncludes from 'core-js-pure/features/string/includes.js'; const DEFAULT_CMP = 'iab'; const DEFAULT_CONSENT_TIMEOUT = 10000; const DEFAULT_ALLOW_AUCTION_WO_CONSENT = true; export const allowAuction = { value: DEFAULT_ALLOW_AUCTION_WO_CONSENT, definedInConfig: false } export let userCMP; export let consentTimeout; export let gdprScope; export let staticConsentData; let cmpVersion = 0; let consentData; let addedConsentHook = false; // add new CMPs here, with their dedicated lookup function const cmpCallMap = { 'iab': lookupIabConsent, 'static': lookupStaticConsentData }; /** * This function reads the consent string from the config to obtain the consent information of the user. * @param {function(string)} cmpSuccess acts as a success callback when the value is read from config; pass along consentObject (string) from CMP * @param {function(string)} cmpError acts as an error callback while interacting with the config string; pass along an error message (string) * @param {object} hookConfig contains module related variables (see comment in requestBidsHook function) */ function lookupStaticConsentData(cmpSuccess, cmpError, hookConfig) { cmpSuccess(staticConsentData, hookConfig); } /** * This function handles interacting with an IAB compliant CMP to obtain the consent information of the user. * Given the async nature of the CMP's API, we pass in acting success/error callback functions to exit this function * based on the appropriate result. * @param {function(string)} cmpSuccess acts as a success callback when CMP returns a value; pass along consentObject (string) from CMP * @param {function(string)} cmpError acts as an error callback while interacting with CMP; pass along an error message (string) * @param {object} hookConfig contains module related variables (see comment in requestBidsHook function) */ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) { function findCMP() { let f = window; let cmpFrame; let cmpFunction; while (!cmpFrame) { try { if (typeof f.__tcfapi === 'function' || typeof f.__cmp === 'function') { if (typeof f.__tcfapi === 'function') { cmpVersion = 2; cmpFunction = f.__tcfapi; } else { cmpVersion = 1; cmpFunction = f.__cmp; } cmpFrame = f; break; } } catch (e) { } // need separate try/catch blocks due to the exception errors thrown when trying to check for a frame that doesn't exist in 3rd party env try { if (f.frames['__tcfapiLocator']) { cmpVersion = 2; cmpFrame = f; break; } } catch (e) { } try { if (f.frames['__cmpLocator']) { cmpVersion = 1; cmpFrame = f; break; } } catch (e) { } if (f === window.top) break; f = f.parent; } return { cmpFrame, cmpFunction }; } function v2CmpResponseCallback(tcfData, success) { utils.logInfo('Received a response from CMP', tcfData); if (success) { if (tcfData.gdprApplies === false || tcfData.eventStatus === 'tcloaded' || tcfData.eventStatus === 'useractioncomplete') { cmpSuccess(tcfData, hookConfig); } } else { cmpError('CMP unable to register callback function. Please check CMP setup.', hookConfig); } } function handleV1CmpResponseCallbacks() { const cmpResponse = {}; function afterEach() { if (cmpResponse.getConsentData && cmpResponse.getVendorConsents) { utils.logInfo('Received all requested responses from CMP', cmpResponse); cmpSuccess(cmpResponse, hookConfig); } } return { consentDataCallback: function (consentResponse) { cmpResponse.getConsentData = consentResponse; afterEach(); }, vendorConsentsCallback: function (consentResponse) { cmpResponse.getVendorConsents = consentResponse; afterEach(); } } } let v1CallbackHandler = handleV1CmpResponseCallbacks(); let cmpCallbacks = {}; let { cmpFrame, cmpFunction } = findCMP(); if (!cmpFrame) { return cmpError('CMP not found.', hookConfig); } // to collect the consent information from the user, we perform two calls to the CMP in parallel: // first to collect the user's consent choices represented in an encoded string (via getConsentData) // second to collect the user's full unparsed consent information (via getVendorConsents) // the following code also determines where the CMP is located and uses the proper workflow to communicate with it: // check to see if CMP is found on the same window level as prebid and call it directly if so // check to see if prebid is in a safeframe (with CMP support) // else assume prebid may be inside an iframe and use the IAB CMP locator code to see if CMP's located in a higher parent window. this works in cross domain iframes // if the CMP is not found, the iframe function will call the cmpError exit callback to abort the rest of the CMP workflow if (utils.isFn(cmpFunction)) { utils.logInfo('Detected CMP API is directly accessible, calling it now...'); if (cmpVersion === 1) { cmpFunction('getConsentData', null, v1CallbackHandler.consentDataCallback); cmpFunction('getVendorConsents', null, v1CallbackHandler.vendorConsentsCallback); } else if (cmpVersion === 2) { cmpFunction('addEventListener', cmpVersion, v2CmpResponseCallback); } } else if (cmpVersion === 1 && inASafeFrame() && typeof window.$sf.ext.cmp === 'function') { // this safeframe workflow is only supported with TCF v1 spec; the v2 recommends to use the iframe postMessage route instead (even if you are in a safeframe). utils.logInfo('Detected Prebid.js is encased in a SafeFrame and CMP is registered, calling it now...'); callCmpWhileInSafeFrame('getConsentData', v1CallbackHandler.consentDataCallback); callCmpWhileInSafeFrame('getVendorConsents', v1CallbackHandler.vendorConsentsCallback); } else { utils.logInfo('Detected CMP is outside the current iframe where Prebid.js is located, calling it now...'); if (cmpVersion === 1) { callCmpWhileInIframe('getConsentData', cmpFrame, v1CallbackHandler.consentDataCallback); callCmpWhileInIframe('getVendorConsents', cmpFrame, v1CallbackHandler.vendorConsentsCallback); } else if (cmpVersion === 2) { callCmpWhileInIframe('addEventListener', cmpFrame, v2CmpResponseCallback); } } function inASafeFrame() { return !!(window.$sf && window.$sf.ext); } function callCmpWhileInSafeFrame(commandName, callback) { function sfCallback(msgName, data) { if (msgName === 'cmpReturn') { let responseObj = (commandName === 'getConsentData') ? data.vendorConsentData : data.vendorConsents; callback(responseObj); } } // find sizes from adUnits object let adUnits = hookConfig.adUnits; let width = 1; let height = 1; if (Array.isArray(adUnits) && adUnits.length > 0) { let sizes = utils.getAdUnitSizes(adUnits[0]); width = sizes[0][0]; height = sizes[0][1]; } window.$sf.ext.register(width, height, sfCallback); window.$sf.ext.cmp(commandName); } function callCmpWhileInIframe(commandName, cmpFrame, moduleCallback) { let apiName = (cmpVersion === 2) ? '__tcfapi' : '__cmp'; let callName = `${apiName}Call`; /* Setup up a __cmp function to do the postMessage and stash the callback. This function behaves (from the caller's perspective identicially to the in-frame __cmp call */ if (cmpVersion === 2) { window[apiName] = function (cmd, cmpVersion, callback, arg) { let callId = Math.random() + ''; let msg = { [callName]: { command: cmd, version: cmpVersion, parameter: arg, callId: callId } }; cmpCallbacks[callId] = callback; cmpFrame.postMessage(msg, '*'); } /** when we get the return message, call the stashed callback */ window.addEventListener('message', readPostMessageResponse, false); // call CMP window[apiName](commandName, cmpVersion, moduleCallback); } else { window[apiName] = function (cmd, arg, callback) { let callId = Math.random() + ''; let msg = { [callName]: { command: cmd, parameter: arg, callId: callId } }; cmpCallbacks[callId] = callback; cmpFrame.postMessage(msg, '*'); } /** when we get the return message, call the stashed callback */ window.addEventListener('message', readPostMessageResponse, false); // call CMP window[apiName](commandName, undefined, moduleCallback); } function readPostMessageResponse(event) { let cmpDataPkgName = `${apiName}Return`; let json = (typeof event.data === 'string' && strIncludes(event.data, cmpDataPkgName)) ? JSON.parse(event.data) : event.data; if (json[cmpDataPkgName] && json[cmpDataPkgName].callId) { let payload = json[cmpDataPkgName]; // TODO - clean up this logic (move listeners?); we have duplicate messages responses because 2 eventlisteners are active from the 2 cmp requests running in parallel if (typeof cmpCallbacks[payload.callId] !== 'undefined') { cmpCallbacks[payload.callId](payload.returnValue, payload.success); } } } } } /** * If consentManagement module is enabled (ie included in setConfig), this hook function will attempt to fetch the * user's encoded consent string from the supported CMP. Once obtained, the module will store this * data as part of a gdprConsent object which gets transferred to adapterManager's gdprDataHandler object. * This information is later added into the bidRequest object for any supported adapters to read/pass along to their system. * @param {object} reqBidsConfigObj required; This is the same param that's used in pbjs.requestBids. * @param {function} fn required; The next function in the chain, used by hook.js */ export function requestBidsHook(fn, reqBidsConfigObj) { // preserves all module related variables for the current auction instance (used primiarily for concurrent auctions) const hookConfig = { context: this, args: [reqBidsConfigObj], nextFn: fn, adUnits: reqBidsConfigObj.adUnits || $$PREBID_GLOBAL$$.adUnits, bidsBackHandler: reqBidsConfigObj.bidsBackHandler, haveExited: false, timer: null }; // in case we already have consent (eg during bid refresh) if (consentData) { utils.logInfo('User consent information already known. Pulling internally stored information...'); return exitModule(null, hookConfig); } if (!includes(Object.keys(cmpCallMap), userCMP)) { utils.logWarn(`CMP framework (${userCMP}) is not a supported framework. Aborting consentManagement module and resuming auction.`); return hookConfig.nextFn.apply(hookConfig.context, hookConfig.args); } cmpCallMap[userCMP].call(this, processCmpData, cmpFailed, hookConfig); // only let this code run if module is still active (ie if the callbacks used by CMPs haven't already finished) if (!hookConfig.haveExited) { if (consentTimeout === 0) { processCmpData(undefined, hookConfig); } else { hookConfig.timer = setTimeout(cmpTimedOut.bind(null, hookConfig), consentTimeout); } } } /** * This function checks the consent data provided by CMP to ensure it's in an expected state. * If it's bad, we exit the module depending on config settings. * If it's good, then we store the value and exits the module. * @param {object} consentObject required; object returned by CMP that contains user's consent choices * @param {object} hookConfig contains module related variables (see comment in requestBidsHook function) */ function processCmpData(consentObject, hookConfig) { function checkV1Data(consentObject) { let gdprApplies = consentObject && consentObject.getConsentData && consentObject.getConsentData.gdprApplies; return !!( (typeof gdprApplies !== 'boolean') || (gdprApplies === true && !(utils.isStr(consentObject.getConsentData.consentData) && utils.isPlainObject(consentObject.getVendorConsents) && Object.keys(consentObject.getVendorConsents).length > 1 ) ) ); } function checkV2Data() { // if CMP does not respond with a gdprApplies boolean, use defaultGdprScope (gdprScope) let gdprApplies = consentObject && typeof consentObject.gdprApplies === 'boolean' ? consentObject.gdprApplies : gdprScope; let tcString = consentObject && consentObject.tcString; return !!( (typeof gdprApplies !== 'boolean') || (gdprApplies === true && !utils.isStr(tcString)) ); } // do extra things for static config if (userCMP === 'static') { cmpVersion = (consentObject.getConsentData) ? 1 : (consentObject.getTCData) ? 2 : 0; // remove extra layer in static v2 data object so it matches normal v2 CMP object for processing step if (cmpVersion === 2) { consentObject = consentObject.getTCData; } } // determine which set of checks to run based on cmpVersion let checkFn = (cmpVersion === 1) ? checkV1Data : (cmpVersion === 2) ? checkV2Data : null; // Raise deprecation warning if 'allowAuctionWithoutConsent' is used with TCF 2. if (allowAuction.definedInConfig && cmpVersion === 2) { utils.logWarn(`'allowAuctionWithoutConsent' ignored for TCF 2`); } else if (!allowAuction.definedInConfig && cmpVersion === 1) { utils.logInfo(`'allowAuctionWithoutConsent' using system default: (${DEFAULT_ALLOW_AUCTION_WO_CONSENT}).`); } if (utils.isFn(checkFn)) { if (checkFn(consentObject)) { cmpFailed(`CMP returned unexpected value during lookup process.`, hookConfig, consentObject); } else { clearTimeout(hookConfig.timer); storeConsentData(consentObject); exitModule(null, hookConfig); } } else { cmpFailed('Unable to derive CMP version to process data. Consent object does not conform to TCF v1 or v2 specs.', hookConfig, consentObject); } } /** * General timeout callback when interacting with CMP takes too long. */ function cmpTimedOut(hookConfig) { cmpFailed('CMP workflow exceeded timeout threshold.', hookConfig); } /** * This function contains the controlled steps to perform when there's a problem with CMP. * @param {string} errMsg required; should be a short descriptive message for why the failure/issue happened. * @param {object} hookConfig contains module related variables (see comment in requestBidsHook function) * @param {object} extraArgs contains additional data that's passed along in the error/warning messages for easier debugging */ function cmpFailed(errMsg, hookConfig, extraArgs) { clearTimeout(hookConfig.timer); // still set the consentData to undefined when there is a problem as per config options if (allowAuction.value && cmpVersion === 1) { storeConsentData(undefined); } exitModule(errMsg, hookConfig, extraArgs); } /** * Stores CMP data locally in module and then invokes gdprDataHandler.setConsentData() to make information available in adaptermanager.js for later in the auction * @param {object} cmpConsentObject required; an object representing user's consent choices (can be undefined in certain use-cases for this function only) */ function storeConsentData(cmpConsentObject) { if (cmpVersion === 1) { consentData = { consentString: (cmpConsentObject) ? cmpConsentObject.getConsentData.consentData : undefined, vendorData: (cmpConsentObject) ? cmpConsentObject.getVendorConsents : undefined, gdprApplies: (cmpConsentObject) ? cmpConsentObject.getConsentData.gdprApplies : gdprScope }; } else { consentData = { consentString: (cmpConsentObject) ? cmpConsentObject.tcString : undefined, vendorData: (cmpConsentObject) || undefined, gdprApplies: cmpConsentObject && typeof cmpConsentObject.gdprApplies === 'boolean' ? cmpConsentObject.gdprApplies : gdprScope }; if (cmpConsentObject && cmpConsentObject.addtlConsent && utils.isStr(cmpConsentObject.addtlConsent)) { consentData.addtlConsent = cmpConsentObject.addtlConsent; }; } consentData.apiVersion = cmpVersion; gdprDataHandler.setConsentData(consentData); } /** * This function handles the exit logic for the module. * While there are several paths in the module's logic to call this function, we only allow 1 of the 3 potential exits to happen before suppressing others. * * We prevent multiple exits to avoid conflicting messages in the console depending on certain scenarios. * One scenario could be auction was canceled due to timeout with CMP being reached. * While the timeout is the accepted exit and runs first, the CMP's callback still tries to process the user's data (which normally leads to a good exit). * In this case, the good exit will be suppressed since we already decided to cancel the auction. * * Three exit paths are: * 1. good exit where auction runs (CMP data is processed normally). * 2. bad exit but auction still continues (warning message is logged, CMP data is undefined and still passed along). * 3. bad exit with auction canceled (error message is logged). * @param {string} errMsg optional; only to be used when there was a 'bad' exit. String is a descriptive message for the failure/issue encountered. * @param {object} hookConfig contains module related variables (see comment in requestBidsHook function) * @param {object} extraArgs contains additional data that's passed along in the error/warning messages for easier debugging */ function exitModule(errMsg, hookConfig, extraArgs) { if (hookConfig.haveExited === false) { hookConfig.haveExited = true; let context = hookConfig.context; let args = hookConfig.args; let nextFn = hookConfig.nextFn; if (errMsg) { if (allowAuction.value && cmpVersion === 1) { utils.logWarn(errMsg + ` 'allowAuctionWithoutConsent' activated.`, extraArgs); nextFn.apply(context, args); } else { utils.logError(errMsg + ' Canceling auction as per consentManagement config.', extraArgs); if (typeof hookConfig.bidsBackHandler === 'function') { hookConfig.bidsBackHandler(); } else { utils.logError('Error executing bidsBackHandler'); } } } else { nextFn.apply(context, args); } } } /** * Simply resets the module's consentData variable back to undefined, mainly for testing purposes */ export function resetConsentData() { consentData = undefined; userCMP = undefined; cmpVersion = 0; gdprDataHandler.setConsentData(null); } /** * A configuration function that initializes some module variables, as well as add a hook into the requestBids function * @param {{cmp:string, timeout:number, allowAuctionWithoutConsent:boolean, defaultGdprScope:boolean}} config required; consentManagement module config settings; cmp (string), timeout (int), allowAuctionWithoutConsent (boolean) */ export function setConsentConfig(config) { // if `config.gdpr` or `config.usp` exist, assume new config format. // else for backward compatability, just use `config` config = config && (config.gdpr || config.usp ? config.gdpr : config); if (!config || typeof config !== 'object') { utils.logWarn('consentManagement config not defined, exiting consent manager'); return; } if (utils.isStr(config.cmpApi)) { userCMP = config.cmpApi; } else { userCMP = DEFAULT_CMP; utils.logInfo(`consentManagement config did not specify cmp. Using system default setting (${DEFAULT_CMP}).`); } if (utils.isNumber(config.timeout)) { consentTimeout = config.timeout; } else { consentTimeout = DEFAULT_CONSENT_TIMEOUT; utils.logInfo(`consentManagement config did not specify timeout. Using system default setting (${DEFAULT_CONSENT_TIMEOUT}).`); } if (typeof config.allowAuctionWithoutConsent === 'boolean') { allowAuction.value = config.allowAuctionWithoutConsent; allowAuction.definedInConfig = true; } // if true, then gdprApplies should be set to true gdprScope = config.defaultGdprScope === true; utils.logInfo('consentManagement module has been activated...'); if (userCMP === 'static') { if (utils.isPlainObject(config.consentData)) { staticConsentData = config.consentData; consentTimeout = 0; } else { utils.logError(`consentManagement config with cmpApi: 'static' did not specify consentData. No consents will be available to adapters.`); } } if (!addedConsentHook) { $$PREBID_GLOBAL$$.requestBids.before(requestBidsHook, 50); } addedConsentHook = true; } config.getConfig('consentManagement', config => setConsentConfig(config.consentManagement));