Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CCPA additions #4502

Merged
merged 23 commits into from
Dec 4, 2019
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 193 additions & 11 deletions modules/consentManagement.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/
import * as utils from '../src/utils';
import { config } from '../src/config';
import { gdprDataHandler } from '../src/adapterManager';
import { gdprDataHandler, ccpaDataHandler } from '../src/adapterManager';
import includes from 'core-js/library/fn/array/includes';
import strIncludes from 'core-js/library/fn/string/includes';

Expand All @@ -22,9 +22,19 @@ export let staticConsentData;
let consentData;
let addedConsentHook = false;

// ccpa constants
export let userCCPA;
export let consentTimeoutCCPA;

// ccpa globals
let consentDataCCPA;
let addedConsentHookCCPA = false;

tjeastmond marked this conversation as resolved.
Show resolved Hide resolved
// add new CMPs here, with their dedicated lookup function
const cmpCallMap = {
'iab': lookupIabConsent,
'gdpr': lookupIabConsent,
'ccpa': lookupCcpaConsent,
'static': lookupStaticConsentData
};

Expand Down Expand Up @@ -182,6 +192,138 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
}
}

function lookupCcpaConsent(ccpaSucess, cmpError, hookConfig) {
function handleCmpResponseCallbacks() {
const ccpaResponse = {};

function afterEach() {
if (ccpaResponse.consentString) {
ccpaSucess(ccpaResponse, hookConfig);
}
}

return {
consentDataCallback: function (consentResponse) {
ccpaResponse.consentString = consentResponse.consentString;
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved
afterEach();
}
}
}

let callbackHandler = handleCmpResponseCallbacks();
let uspapiCallbacks = {};
let ccpaFunction;

// 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)
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved

// 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)
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved
// 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
try {
ccpaFunction = window.__uspapi || utils.getWindowTop().__uspapi;
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved
} catch (e) { }

if (utils.isFn(ccpaFunction)) {
ccpaFunction('getConsentData', null, callbackHandler.consentDataCallback);
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved
} else if (inASafeFrame() && typeof window.$sf.ext.uspapi === 'function') {
callCcpaWhileInSafeFrame('getConsentData', callbackHandler.consentDataCallback);
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved
} else {
// find the CMP frame
let f = window;
let ccpaFrame;
while (!ccpaFrame) {
try {
if (f.frames['__uspapiLocator']) ccpaFrame = f;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small point, but in the first iteration of the loop, this checks the current window. This is unnecessary because we would already have found the __uspapi() function if the uspapi was in the current window.

the issue had a function that skips the current window:

let w = window;
let uspWin;
while (!uspWin && w !== window.top) {
    w = w.parent;
    try {
        if (w.frames["__uspapiLocator"]) uspWin = w;
    } catch(e) {}
}
if (!uspWin) {
    return; // no USP API window found, so bail
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used this function for Emily and multi-sync:

function getConsentWindowFor(loc) {
    var win = window;
    while (win !== window.top) {
        win = win.parent;
        if (win.frames[loc]) return win.frames[loc];
    }
    return false;
}

I will implement.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed all the other type lookups but left the old logic for finding the proper iFrame for now. I will need to tweak tests.

} catch (e) { }
if (f === window.top) break;
f = f.parent;
}

if (!ccpaFrame) {
return cmpError('CCPA not found.', hookConfig);
}

callCcpaWhileInIframe('getConsentData', ccpaFrame, callbackHandler.consentDataCallback);
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved
}

function inASafeFrame() {
return !!(window.$sf && window.$sf.ext);
}

function callCcpaWhileInSafeFrame(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.ccpa(commandName);
}
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved

function callCcpaWhileInIframe(commandName, ccpaFrame, moduleCallback) {
/* Setup up a __ccpa function to do the postMessage and stash the callback.
This function behaves (from the caller's perspective identicially to the in-frame __ccpa call */
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved
window.__uspapi = function (cmd, ver, callback) {
let callId = Math.random() + '';
let msg = {
__uspapiCall: {
command: cmd,
version: ver,
callId: callId
}
};
uspapiCallbacks[callId] = callback;
ccpaFrame.postMessage(msg, '*');
};

/** when we get the return message, call the stashed callback */
window.addEventListener('message', readPostMessageResponse, false);

// call ccpa
window.__uspapi(commandName, 1, ccpaIframeCallback);

function readPostMessageResponse(event) {
let res = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
if (res.__uspapiReturn && res.__uspapiReturn.callId) {
let i = res.__uspapiReturn;
if (typeof uspapiCallbacks[i.callId] !== 'undefined') {
uspapiCallbacks[i.callId](i.returnValue, i.success);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correctly passing both the returnValue and success params, but the callback only handles the first param.

delete uspapiCallbacks[i.callId];
}
}
}

function removePostMessageListener() {
window.removeEventListener('message', readPostMessageResponse, false);
}

function ccpaIframeCallback(consentObject) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the callback has 2 params, returnValue and success, but only handling one here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I see what you're talking about. Maybe a commit moved the line?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe ccpaIframeCallback() is the function called from uspapiCallbacks[i.callId](i.returnValue, i.success); above. We shouldn't be trying to process the return value if it wasn't successful.

removePostMessageListener();
moduleCallback(consentObject);
}
}
}

export function requestCcpaBidsHook(next, reqBidsConfigObj) {
requestBidsHook(next, reqBidsConfigObj, true);
}

/**
* 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 @@ -190,7 +332,15 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
* @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) {
export function requestBidsHook(fn, reqBidsConfigObj, isCCPA = false) {
let userModule = userCMP;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as per a comment below, I'm not sure this is being used properly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comment below your comment below.

let processFn = processCmpData;

if (isCCPA) {
userModule = 'ccpa';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A workaround for the global params. I could probably refactor it away.

processFn = processCcpaData; // @TJ
}

// preserves all module related variables for the current auction instance (used primiarily for concurrent auctions)
const hookConfig = {
context: this,
Expand All @@ -199,25 +349,26 @@ export function requestBidsHook(fn, reqBidsConfigObj) {
adUnits: reqBidsConfigObj.adUnits || $$PREBID_GLOBAL$$.adUnits,
bidsBackHandler: reqBidsConfigObj.bidsBackHandler,
haveExited: false,
timer: null
timer: null,
userModule: userModule
};

// in case we already have consent (eg during bid refresh)
if (consentData) {
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.`);
if (!includes(Object.keys(cmpCallMap), userModule)) {
utils.logWarn(`CMP framework (${userModule}) 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);
cmpCallMap[userModule].call(this, processFn, 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);
processFn(undefined, hookConfig);
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved
} else {
hookConfig.timer = setTimeout(cmpTimedOut.bind(null, hookConfig), consentTimeout);
}
Expand Down Expand Up @@ -251,6 +402,17 @@ function processCmpData(consentObject, hookConfig) {
}
}

function processCcpaData (consentObject, hookConfig) {
if (!(consentObject && consentObject.consentString)) {
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved
cmpFailed(`CCPA returned unexpected value during lookup process.`, hookConfig, consentObject);
return;
}

clearTimeout(hookConfig.timer);
storeCcpaConsentData(consentObject);
exitModule(null, hookConfig);
}

/**
* General timeout callback when interacting with CMP takes too long.
*/
Expand Down Expand Up @@ -287,6 +449,13 @@ function storeConsentData(cmpConsentObject) {
gdprDataHandler.setConsentData(consentData);
}

function storeCcpaConsentData(consentObject) {
consentData = {
consentString: consentObject ? consentObject.consentString : undefined
};
ccpaDataHandler.setConsentData(consentData);
}

/**
* This function handles the exit logic for the module.
* There are several paths in the module's logic to call this function and we only allow 1 of the 3 potential exits to happen before suppressing others.
Expand Down Expand Up @@ -336,13 +505,14 @@ function exitModule(errMsg, hookConfig, extraArgs) {
export function resetConsentData() {
consentData = undefined;
gdprDataHandler.setConsentData(null);
ccpaDataHandler.setConsentData(null);
}

/**
* A configuration function that initializes some module variables, as well as add a hook into the requestBids function
* @param {object} config required; consentManagement module config settings; cmp (string), timeout (int), allowAuctionWithoutConsent (boolean)
*/
export function setConsentConfig(config) {
export function setConsentConfig(config, consentModule) {
if (utils.isStr(config.cmpApi)) {
userCMP = config.cmpApi;
} else {
Expand Down Expand Up @@ -374,9 +544,21 @@ export function setConsentConfig(config) {
utils.logError(`consentManagement config with cmpApi: 'static' did not specify consentData. No consents will be available to adapters.`);
}
}
if (!addedConsentHook) {

if (consentModule === 'ccpa' && !addedConsentHookCCPA) {
$$PREBID_GLOBAL$$.requestBids.before(requestCcpaBidsHook, 50);
addedConsentHookCCPA = true;
}

if (!addedConsentHook && consentModule !== 'ccpa') {
$$PREBID_GLOBAL$$.requestBids.before(requestBidsHook, 50);
addedConsentHook = true;
}
addedConsentHook = true;
}
config.getConfig('consentManagement', config => setConsentConfig(config.consentManagement));

config.getConfig('consentManagement', config => {
const consentManagement = { ...config.consentManagement };
const consentChecks = consentManagement.consentAPIs ? new Set([...consentManagement.consentAPIs]) : new Set([]);
tjeastmond marked this conversation as resolved.
Show resolved Hide resolved
if (utils.isStr(config.cmpApi)) consentChecks.add('iab');
[...consentChecks].map(module => setConsentConfig(consentManagement, module));
});
10 changes: 10 additions & 0 deletions src/adapterManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ export let gdprDataHandler = {
}
};

export let ccpaDataHandler = {
consentData: null,
setConsentData: function(consentInfo) {
ccpaDataHandler.consentData = consentInfo;
},
getConsentData: function() {
return ccpaDataHandler.consentData;
}
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where is this being used? I see that the gdprDataHandler is used to add consent to the bid requests.


adapterManager.makeBidRequests = function(adUnits, auctionStart, auctionId, cbTimeout, labels) {
let bidRequests = [];

Expand Down
Loading