Skip to content

Commit 6bfb5dd

Browse files
jsnellbakeriggyfisk
authored andcommitted
add support for TCF2 (prebid#4911)
* initial support for TCF2 * update wording in err message * update logic and comment around safeframe workflow * fix typo
1 parent 51dcd56 commit 6bfb5dd

File tree

3 files changed

+486
-591
lines changed

3 files changed

+486
-591
lines changed

modules/consentManagement.js

+158-65
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export let consentTimeout;
1919
export let allowAuction;
2020
export let staticConsentData;
2121

22+
let cmpVersion = 0;
2223
let consentData;
2324
let addedConsentHook = false;
2425

@@ -47,11 +48,70 @@ function lookupStaticConsentData(cmpSuccess, cmpError, hookConfig) {
4748
* @param {object} hookConfig contains module related variables (see comment in requestBidsHook function)
4849
*/
4950
function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
50-
function handleCmpResponseCallbacks() {
51+
function findCMP() {
52+
let f = window;
53+
let cmpFrame;
54+
let cmpFunction;
55+
while (!cmpFrame) {
56+
try {
57+
if (typeof f.__tcfapi === 'function' || typeof f.__cmp === 'function') {
58+
if (typeof f.__tcfapi === 'function') {
59+
cmpVersion = 2;
60+
cmpFunction = f.__tcfapi;
61+
} else {
62+
cmpVersion = 1;
63+
cmpFunction = f.__cmp;
64+
}
65+
cmpFrame = f;
66+
break;
67+
}
68+
} catch (e) { }
69+
70+
// 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
71+
try {
72+
if (f.frames['__tcfapiLocator']) {
73+
cmpVersion = 2;
74+
cmpFrame = f;
75+
break;
76+
}
77+
} catch (e) { }
78+
79+
try {
80+
if (f.frames['__cmpLocator']) {
81+
cmpVersion = 1;
82+
cmpFrame = f;
83+
break;
84+
}
85+
} catch (e) { }
86+
87+
if (f === window.top) break;
88+
f = f.parent;
89+
}
90+
return {
91+
cmpFrame,
92+
cmpFunction
93+
};
94+
}
95+
96+
function v2CmpResponseCallback(tcfData, success) {
97+
utils.logInfo('Received a response from CMP', tcfData);
98+
if (success) {
99+
if (tcfData.eventStatus === 'tcloaded' || tcfData.eventStatus === 'useractioncomplete') {
100+
cmpSuccess(tcfData, hookConfig);
101+
} else if (tcfData.eventStatus === 'cmpuishown' && tcfData.tcString.length > 0 && tcfData.purposeOneTreatment === true) {
102+
cmpSuccess(tcfData, hookConfig);
103+
}
104+
} else {
105+
cmpError('CMP unable to register callback function. Please check CMP setup.', hookConfig);
106+
}
107+
}
108+
109+
function handleV1CmpResponseCallbacks() {
51110
const cmpResponse = {};
52111

53112
function afterEach() {
54113
if (cmpResponse.getConsentData && cmpResponse.getVendorConsents) {
114+
utils.logInfo('Received all requested responses from CMP', cmpResponse);
55115
cmpSuccess(cmpResponse, hookConfig);
56116
}
57117
}
@@ -68,10 +128,13 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
68128
}
69129
}
70130

71-
let callbackHandler = handleCmpResponseCallbacks();
131+
let v1CallbackHandler = handleV1CmpResponseCallbacks();
72132
let cmpCallbacks = {};
73-
let cmpFunction;
133+
let { cmpFrame, cmpFunction } = findCMP();
74134

135+
if (!cmpFrame) {
136+
return cmpError('CMP not found.', hookConfig);
137+
}
75138
// to collect the consent information from the user, we perform two calls to the CMP in parallel:
76139
// first to collect the user's consent choices represented in an encoded string (via getConsentData)
77140
// second to collect the user's full unparsed consent information (via getVendorConsents)
@@ -81,34 +144,28 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
81144
// check to see if prebid is in a safeframe (with CMP support)
82145
// 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
83146
// if the CMP is not found, the iframe function will call the cmpError exit callback to abort the rest of the CMP workflow
84-
try {
85-
cmpFunction = window.__cmp || utils.getWindowTop().__cmp;
86-
} catch (e) { }
87147

88148
if (utils.isFn(cmpFunction)) {
89-
cmpFunction('getConsentData', null, callbackHandler.consentDataCallback);
90-
cmpFunction('getVendorConsents', null, callbackHandler.vendorConsentsCallback);
91-
} else if (inASafeFrame() && typeof window.$sf.ext.cmp === 'function') {
92-
callCmpWhileInSafeFrame('getConsentData', callbackHandler.consentDataCallback);
93-
callCmpWhileInSafeFrame('getVendorConsents', callbackHandler.vendorConsentsCallback);
94-
} else {
95-
// find the CMP frame
96-
let f = window;
97-
let cmpFrame;
98-
while (!cmpFrame) {
99-
try {
100-
if (f.frames['__cmpLocator']) cmpFrame = f;
101-
} catch (e) { }
102-
if (f === window.top) break;
103-
f = f.parent;
149+
utils.logInfo('Detected CMP API is directly accessible, calling it now...');
150+
if (cmpVersion === 1) {
151+
cmpFunction('getConsentData', null, v1CallbackHandler.consentDataCallback);
152+
cmpFunction('getVendorConsents', null, v1CallbackHandler.vendorConsentsCallback);
153+
} else if (cmpVersion === 2) {
154+
cmpFunction('addEventListener', cmpVersion, v2CmpResponseCallback);
104155
}
105-
106-
if (!cmpFrame) {
107-
return cmpError('CMP not found.', hookConfig);
156+
} else if (cmpVersion === 1 && inASafeFrame() && typeof window.$sf.ext.cmp === 'function') {
157+
// 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).
158+
utils.logInfo('Detected Prebid.js is encased in a SafeFrame and CMP is registered, calling it now...');
159+
callCmpWhileInSafeFrame('getConsentData', v1CallbackHandler.consentDataCallback);
160+
callCmpWhileInSafeFrame('getVendorConsents', v1CallbackHandler.vendorConsentsCallback);
161+
} else {
162+
utils.logInfo('Detected CMP is outside the current iframe where Prebid.js is located, calling it now...');
163+
if (cmpVersion === 1) {
164+
callCmpWhileInIframe('getConsentData', cmpFrame, v1CallbackHandler.consentDataCallback);
165+
callCmpWhileInIframe('getVendorConsents', cmpFrame, v1CallbackHandler.vendorConsentsCallback);
166+
} else if (cmpVersion === 2) {
167+
callCmpWhileInIframe('addEventListener', cmpFrame, v2CmpResponseCallback);
108168
}
109-
110-
callCmpWhileInIframe('getConsentData', cmpFrame, callbackHandler.consentDataCallback);
111-
callCmpWhileInIframe('getVendorConsents', cmpFrame, callbackHandler.vendorConsentsCallback);
112169
}
113170

114171
function inASafeFrame() {
@@ -138,17 +195,22 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
138195
}
139196

140197
function callCmpWhileInIframe(commandName, cmpFrame, moduleCallback) {
198+
let apiName = (cmpVersion === 2) ? '__tcfapi' : '__cmp';
199+
141200
/* Setup up a __cmp function to do the postMessage and stash the callback.
142201
This function behaves (from the caller's perspective identicially to the in-frame __cmp call */
143-
window.__cmp = function (cmd, arg, callback) {
202+
window[apiName] = function (cmd, arg, callback) {
144203
let callId = Math.random() + '';
204+
let callName = `${apiName}Call`;
145205
let msg = {
146-
__cmpCall: {
206+
[callName]: {
147207
command: cmd,
148208
parameter: arg,
149209
callId: callId
150210
}
151211
};
212+
if (cmpVersion !== 1) msg[callName].version = cmpVersion;
213+
152214
cmpCallbacks[callId] = callback;
153215
cmpFrame.postMessage(msg, '*');
154216
}
@@ -157,28 +219,19 @@ function lookupIabConsent(cmpSuccess, cmpError, hookConfig) {
157219
window.addEventListener('message', readPostMessageResponse, false);
158220

159221
// call CMP
160-
window.__cmp(commandName, null, cmpIframeCallback);
222+
window[apiName](commandName, null, moduleCallback);
161223

162224
function readPostMessageResponse(event) {
163-
let json = (typeof event.data === 'string' && strIncludes(event.data, 'cmpReturn')) ? JSON.parse(event.data) : event.data;
164-
if (json.__cmpReturn && json.__cmpReturn.callId) {
165-
let i = json.__cmpReturn;
225+
let cmpDataPkgName = `${apiName}Return`;
226+
let json = (typeof event.data === 'string' && strIncludes(event.data, cmpDataPkgName)) ? JSON.parse(event.data) : event.data;
227+
if (json[cmpDataPkgName] && json[cmpDataPkgName].callId) {
228+
let payload = json[cmpDataPkgName];
166229
// 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
167-
if (typeof cmpCallbacks[i.callId] !== 'undefined') {
168-
cmpCallbacks[i.callId](i.returnValue, i.success);
169-
delete cmpCallbacks[i.callId];
230+
if (typeof cmpCallbacks[payload.callId] !== 'undefined') {
231+
cmpCallbacks[payload.callId](payload.returnValue, payload.success);
170232
}
171233
}
172234
}
173-
174-
function removePostMessageListener() {
175-
window.removeEventListener('message', readPostMessageResponse, false);
176-
}
177-
178-
function cmpIframeCallback(consentObject) {
179-
removePostMessageListener();
180-
moduleCallback(consentObject);
181-
}
182235
}
183236
}
184237

@@ -204,6 +257,7 @@ export function requestBidsHook(fn, reqBidsConfigObj) {
204257

205258
// in case we already have consent (eg during bid refresh)
206259
if (consentData) {
260+
utils.logInfo('User consent information already known. Pulling internally stored information...');
207261
return exitModule(null, hookConfig);
208262
}
209263

@@ -232,22 +286,50 @@ export function requestBidsHook(fn, reqBidsConfigObj) {
232286
* @param {object} hookConfig contains module related variables (see comment in requestBidsHook function)
233287
*/
234288
function processCmpData(consentObject, hookConfig) {
235-
let gdprApplies = consentObject && consentObject.getConsentData && consentObject.getConsentData.gdprApplies;
236-
if (
237-
(typeof gdprApplies !== 'boolean') ||
238-
(gdprApplies === true &&
239-
!(utils.isStr(consentObject.getConsentData.consentData) &&
240-
utils.isPlainObject(consentObject.getVendorConsents) &&
241-
Object.keys(consentObject.getVendorConsents).length > 1
289+
function checkV1Data(consentObject) {
290+
let gdprApplies = consentObject && consentObject.getConsentData && consentObject.getConsentData.gdprApplies;
291+
return !!(
292+
(typeof gdprApplies !== 'boolean') ||
293+
(gdprApplies === true &&
294+
!(utils.isStr(consentObject.getConsentData.consentData) &&
295+
utils.isPlainObject(consentObject.getVendorConsents) &&
296+
Object.keys(consentObject.getVendorConsents).length > 1
297+
)
242298
)
243-
)
244-
) {
245-
cmpFailed(`CMP returned unexpected value during lookup process.`, hookConfig, consentObject);
246-
} else {
247-
clearTimeout(hookConfig.timer);
248-
storeConsentData(consentObject);
299+
);
300+
}
301+
302+
function checkV2Data() {
303+
let gdprApplies = consentObject && consentObject.gdprApplies;
304+
let tcString = consentObject && consentObject.tcString;
305+
return !!(
306+
(typeof gdprApplies !== 'boolean') ||
307+
(gdprApplies === true && !utils.isStr(tcString))
308+
);
309+
}
249310

250-
exitModule(null, hookConfig);
311+
// do extra things for static config
312+
if (userCMP === 'static') {
313+
cmpVersion = (consentObject.getConsentData) ? 1 : (consentObject.getTCData) ? 2 : 0;
314+
// remove extra layer in static v2 data object so it matches normal v2 CMP object for processing step
315+
if (cmpVersion === 2) {
316+
consentObject = consentObject.getTCData;
317+
}
318+
}
319+
320+
// determine which set of checks to run based on cmpVersion
321+
let checkFn = (cmpVersion === 1) ? checkV1Data : (cmpVersion === 2) ? checkV2Data : null;
322+
323+
if (utils.isFn(checkFn)) {
324+
if (checkFn(consentObject)) {
325+
cmpFailed(`CMP returned unexpected value during lookup process.`, hookConfig, consentObject);
326+
} else {
327+
clearTimeout(hookConfig.timer);
328+
storeConsentData(consentObject);
329+
exitModule(null, hookConfig);
330+
}
331+
} else {
332+
cmpFailed('Unable to derive CMP version to process data. Consent object does not conform to TCF v1 or v2 specs.', hookConfig, consentObject);
251333
}
252334
}
253335

@@ -279,17 +361,27 @@ function cmpFailed(errMsg, hookConfig, extraArgs) {
279361
* @param {object} cmpConsentObject required; an object representing user's consent choices (can be undefined in certain use-cases for this function only)
280362
*/
281363
function storeConsentData(cmpConsentObject) {
282-
consentData = {
283-
consentString: (cmpConsentObject) ? cmpConsentObject.getConsentData.consentData : undefined,
284-
vendorData: (cmpConsentObject) ? cmpConsentObject.getVendorConsents : undefined,
285-
gdprApplies: (cmpConsentObject) ? cmpConsentObject.getConsentData.gdprApplies : undefined
286-
};
364+
if (cmpVersion === 1) {
365+
consentData = {
366+
consentString: (cmpConsentObject) ? cmpConsentObject.getConsentData.consentData : undefined,
367+
vendorData: (cmpConsentObject) ? cmpConsentObject.getVendorConsents : undefined,
368+
gdprApplies: (cmpConsentObject) ? cmpConsentObject.getConsentData.gdprApplies : undefined
369+
};
370+
} else {
371+
consentData = {
372+
consentString: (cmpConsentObject) ? cmpConsentObject.tcString : undefined,
373+
vendorData: (cmpConsentObject) || undefined,
374+
gdprApplies: (cmpConsentObject) ? cmpConsentObject.gdprApplies : undefined
375+
};
376+
}
377+
consentData.apiVersion = cmpVersion;
378+
287379
gdprDataHandler.setConsentData(consentData);
288380
}
289381

290382
/**
291383
* This function handles the exit logic for the module.
292-
* 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.
384+
* 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.
293385
*
294386
* We prevent multiple exits to avoid conflicting messages in the console depending on certain scenarios.
295387
* One scenario could be auction was canceled due to timeout with CMP being reached.
@@ -336,6 +428,7 @@ function exitModule(errMsg, hookConfig, extraArgs) {
336428
export function resetConsentData() {
337429
consentData = undefined;
338430
userCMP = undefined;
431+
cmpVersion = 0;
339432
gdprDataHandler.setConsentData(null);
340433
}
341434

modules/userId/index.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,10 @@ function hasGDPRConsent(consentData) {
208208
if (!consentData.consentString) {
209209
return false;
210210
}
211-
if (consentData.vendorData && consentData.vendorData.purposeConsents && consentData.vendorData.purposeConsents['1'] === false) {
211+
if (consentData.apiVersion === 1 && utils.deepAccess(consentData, 'vendorData.purposeConsents.1') === false) {
212+
return false;
213+
}
214+
if (consentData.apiVersion === 2 && utils.deepAccess(consentData, 'vendorData.purpose.consents.1') === false) {
212215
return false;
213216
}
214217
}

0 commit comments

Comments
 (0)