Skip to content

Commit

Permalink
Prebid core: make GDPR/USP consent data available without requiring a…
Browse files Browse the repository at this point in the history
…n auction (#8185)

* Load USP consent data on page load

* Load GPDR consent data on page load

* Update jsdoc
  • Loading branch information
dgirardi authored Apr 2, 2022
1 parent cbb9ee4 commit 70cd775
Show file tree
Hide file tree
Showing 7 changed files with 241 additions and 241 deletions.
278 changes: 134 additions & 144 deletions modules/consentManagement.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,22 @@ const cmpCallMap = {

/**
* 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)
* @param {function({})} onSuccess acts as a success callback when the value is read from config; pass along consentObject from CMP
*/
function lookupStaticConsentData(cmpSuccess, cmpError, hookConfig) {
cmpSuccess(staticConsentData, hookConfig);
function lookupStaticConsentData({onSuccess}) {
onSuccess(staticConsentData);
}

/**
* 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)
* @param {function({})} onSuccess acts as a success callback when CMP returns a value; pass along consentObjectfrom CMP
* @param {function(string, ...{}?)} cmpError acts as an error callback while interacting with CMP; pass along an error message (string) and any extra error arguments (purely for logging)
* @param width
* @param height size info passed to the SafeFrame API (used only for TCFv1 when Prebid is running within a safeframe)
*/
function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
function lookupIabConsent({onSuccess, onError, width, height}) {
function findCMP() {
let f = window;
let cmpFrame;
Expand Down Expand Up @@ -100,10 +99,10 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
logInfo('Received a response from CMP', tcfData);
if (success) {
if (tcfData.gdprApplies === false || tcfData.eventStatus === 'tcloaded' || tcfData.eventStatus === 'useractioncomplete') {
cmpSuccess(tcfData, hookConfig);
processCmpData(tcfData, {onSuccess, onError});
}
} else {
cmpError('CMP unable to register callback function. Please check CMP setup.', hookConfig);
onError('CMP unable to register callback function. Please check CMP setup.');
}
}

Expand All @@ -113,7 +112,7 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
function afterEach() {
if (cmpResponse.getConsentData && cmpResponse.getVendorConsents) {
logInfo('Received all requested responses from CMP', cmpResponse);
cmpSuccess(cmpResponse, hookConfig);
processCmpData(cmpResponse, {onSuccess, onError});
}
}

Expand All @@ -134,7 +133,7 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
let { cmpFrame, cmpFunction } = findCMP();

if (!cmpFrame) {
return cmpError('CMP not found.', hookConfig);
return onError('CMP not found.');
}
// 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)
Expand Down Expand Up @@ -181,16 +180,6 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
}
}

// find sizes from adUnits object
let adUnits = hookConfig.adUnits;
let width = 1;
let height = 1;
if (Array.isArray(adUnits) && adUnits.length > 0) {
let sizes = getAdUnitSizes(adUnits[0]);
width = sizes[0][0];
height = sizes[0][1];
}

window.$sf.ext.register(width, height, sfCallback);
window.$sf.ext.cmp(commandName);
}
Expand Down Expand Up @@ -259,6 +248,70 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
}
}

/**
* Look up consent data and store it in the `consentData` global as well as `adapterManager.js`' gdprDataHandler.
*
* @param cb A callback that takes: a boolean that is true if the auction should be canceled; an error message and extra
* error arguments that will be undefined if there's no error.
* @param width if we are running in an iframe, the TCFv1 spec requires us to use the SafeFrame API to find the CMP - which
* in turn requires width and height.
* @param height see width above
*/
function loadConsentData(cb, width = 1, height = 1) {
let isDone = false;
let timer = null;

function done(consentData, shouldCancelAuction, errMsg, ...extraArgs) {
if (timer != null) {
clearTimeout(timer);
}
isDone = true;
gdprDataHandler.setConsentData(consentData);
if (cb != null) {
cb(shouldCancelAuction, errMsg, ...extraArgs);
}
}

if (!includes(Object.keys(cmpCallMap), userCMP)) {
done(null, false, `CMP framework (${userCMP}) is not a supported framework. Aborting consentManagement module and resuming auction.`);
return;
}

const callbacks = {
onSuccess: (data) => done(data, false),
onError: function (msg, ...extraArgs) {
let consentData = null;
let shouldCancelAuction = true;
if (allowAuction.value && cmpVersion === 1) {
// still set the consentData to undefined when there is a problem as per config options
consentData = storeConsentData(undefined);
shouldCancelAuction = false;
}
done(consentData, shouldCancelAuction, msg, ...extraArgs);
}
}
cmpCallMap[userCMP]({
width,
height,
...callbacks
});

if (!isDone) {
if (consentTimeout === 0) {
processCmpData(undefined, callbacks);
} else {
timer = setTimeout(function () {
if (cmpVersion === 2) {
// for TCFv2, we allow the auction to continue on timeout
done(storeConsentData(undefined), false, `No response from CMP, continuing auction...`)
} else {
callbacks.onError('CMP workflow exceeded timeout threshold.');
}
}, consentTimeout);
}
}
}

/**
* 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
Expand All @@ -268,49 +321,60 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
* @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) {
logInfo('User consent information already known. Pulling internally stored information...');
return exitModule(null, hookConfig);
}
const load = (() => {
if (consentData) {
logInfo('User consent information already known. Pulling internally stored information...');
return function (cb) {
// eslint-disable-next-line standard/no-callback-literal
cb(false);
}
} else {
// find sizes from adUnits object
let adUnits = reqBidsConfigObj.adUnits || $$PREBID_GLOBAL$$.adUnits;
let width = 1;
let height = 1;
if (Array.isArray(adUnits) && adUnits.length > 0) {
let sizes = getAdUnitSizes(adUnits[0]);
width = sizes[0][0];
height = sizes[0][1];
}

if (!includes(Object.keys(cmpCallMap), userCMP)) {
logWarn(`CMP framework (${userCMP}) is not a supported framework. Aborting consentManagement module and resuming auction.`);
gdprDataHandler.setConsentData(null);
return hookConfig.nextFn.apply(hookConfig.context, hookConfig.args);
}
return function (cb) {
loadConsentData(cb, width, height);
}
}
})();

cmpCallMap[userCMP].call(this, processCmpData, cmpFailed, hookConfig);
load(function (shouldCancelAuction, errMsg, ...extraArgs) {
if (errMsg) {
let log = logWarn;
if (cmpVersion === 1 && !shouldCancelAuction) {
errMsg = `${errMsg} 'allowAuctionWithoutConsent' activated.`;
} else if (shouldCancelAuction) {
log = logError;
errMsg = `${errMsg} Canceling auction as per consentManagement config.`;
}
log(errMsg, ...extraArgs);
}

// 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);
if (shouldCancelAuction) {
if (typeof reqBidsConfigObj.bidsBackHandler === 'function') {
reqBidsConfigObj.bidsBackHandler();
} else {
logError('Error executing bidsBackHandler');
}
} else {
hookConfig.timer = setTimeout(cmpTimedOut.bind(null, hookConfig), consentTimeout);
fn.call(this, reqBidsConfigObj);
}
}
});
}

/**
* 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)
* If it's bad, we call `onError`
* If it's good, then we store the value and call `onSuccess`
*/
function processCmpData(consentObject, hookConfig) {
function processCmpData(consentObject, {onSuccess, onError}) {
function checkV1Data(consentObject) {
let gdprApplies = consentObject && consentObject.getConsentData && consentObject.getConsentData.gdprApplies;
return !!(
Expand Down Expand Up @@ -346,57 +410,19 @@ function processCmpData(consentObject, hookConfig) {
// 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) {
logWarn(`'allowAuctionWithoutConsent' ignored for TCF 2`);
} else if (!allowAuction.definedInConfig && cmpVersion === 1) {
logInfo(`'allowAuctionWithoutConsent' using system default: (${DEFAULT_ALLOW_AUCTION_WO_CONSENT}).`);
}

if (isFn(checkFn)) {
if (checkFn(consentObject)) {
cmpFailed(`CMP returned unexpected value during lookup process.`, hookConfig, consentObject);
onError(`CMP returned unexpected value during lookup process.`, consentObject);
} else {
clearTimeout(hookConfig.timer);
storeConsentData(consentObject);
exitModule(null, hookConfig);
onSuccess(storeConsentData(consentObject));
}
} else {
cmpFailed('Unable to derive CMP version to process data. Consent object does not conform to TCF v1 or v2 specs.', hookConfig, consentObject);
onError('Unable to derive CMP version to process data. Consent object does not conform to TCF v1 or v2 specs.', consentObject);
}
}

/**
* General timeout callback when interacting with CMP takes too long.
*/
function cmpTimedOut(hookConfig) {
if (cmpVersion === 2) {
logWarn(`No response from CMP, continuing auction...`)
storeConsentData(undefined);
exitModule(null, hookConfig)
} else {
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
* Stores CMP data locally in module 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) {
Expand All @@ -417,51 +443,7 @@ function storeConsentData(cmpConsentObject) {
};
}
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) {
logWarn(errMsg + ` 'allowAuctionWithoutConsent' activated.`, extraArgs);
nextFn.apply(context, args);
} else {
logError(errMsg + ' Canceling auction as per consentManagement config.', extraArgs);
gdprDataHandler.setConsentData(null);
if (typeof hookConfig.bidsBackHandler === 'function') {
hookConfig.bidsBackHandler();
} else {
logError('Error executing bidsBackHandler');
}
}
} else {
nextFn.apply(context, args);
}
}
return consentData;
}

/**
Expand Down Expand Up @@ -509,7 +491,6 @@ export function setConsentConfig(config) {
gdprScope = config.defaultGdprScope === true;

logInfo('consentManagement module has been activated...');
gdprDataHandler.enable();

if (userCMP === 'static') {
if (isPlainObject(config.consentData)) {
Expand All @@ -523,5 +504,14 @@ export function setConsentConfig(config) {
$$PREBID_GLOBAL$$.requestBids.before(requestBidsHook, 50);
}
addedConsentHook = true;
gdprDataHandler.enable();
loadConsentData(); // immediately look up consent data to make it available without requiring an auction

// Raise deprecation warning if 'allowAuctionWithoutConsent' is used with TCF 2.
if (allowAuction.definedInConfig && cmpVersion === 2) {
logWarn(`'allowAuctionWithoutConsent' ignored for TCF 2`);
} else if (!allowAuction.definedInConfig && cmpVersion === 1) {
logInfo(`'allowAuctionWithoutConsent' using system default: (${DEFAULT_ALLOW_AUCTION_WO_CONSENT}).`);
}
}
config.getConfig('consentManagement', config => setConsentConfig(config.consentManagement));
Loading

0 comments on commit 70cd775

Please sign in to comment.