diff --git a/modules/consentManagement.js b/modules/consentManagement.js index 1e2a6648145..d5703c1a784 100644 --- a/modules/consentManagement.js +++ b/modules/consentManagement.js @@ -335,6 +335,7 @@ function exitModule(errMsg, hookConfig, extraArgs) { */ export function resetConsentData() { consentData = undefined; + userCMP = undefined; gdprDataHandler.setConsentData(null); } @@ -343,6 +344,13 @@ export function resetConsentData() { * @param {object} 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.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 { diff --git a/modules/consentManagementUsp.js b/modules/consentManagementUsp.js new file mode 100644 index 00000000000..af9f4f05dbf --- /dev/null +++ b/modules/consentManagementUsp.js @@ -0,0 +1,292 @@ +/** + * This module adds USPAPI (CCPA) consentManagement support to prebid.js. It + * interacts with supported USP Consent APIs to grab the user's consent + * information and make it available for any USP (CCPA) supported adapters to + * read/pass this information to their system. + */ +import * as utils from '../src/utils'; +import { config } from '../src/config'; +import { uspDataHandler } from '../src/adapterManager'; + +const DEFAULT_CONSENT_API = 'iab'; +const DEFAULT_CONSENT_TIMEOUT = 50; +const USPAPI_VERSION = 1; + +export let consentAPI; +export let consentTimeout; + +let consentData; +let addedConsentHook = false; + +// consent APIs +const uspCallMap = { + 'iab': lookupUspConsent +}; + +/** + * This function handles interacting with an USP compliant consent manager to obtain the consent information of the user. + * Given the async nature of the USP's API, we pass in acting success/error callback functions to exit this function + * based on the appropriate result. + * @param {function(string)} uspSuccess acts as a success callback when USPAPI returns a value; pass along consentObject (string) from UPSAPI + * @param {function(string)} uspError acts as an error callback while interacting with USPAPI; pass along an error message (string) + * @param {object} hookConfig contains module related variables (see comment in requestBidsHook function) + */ +function lookupUspConsent(uspSuccess, uspError, hookConfig) { + function handleUspApiResponseCallbacks() { + const uspResponse = {}; + + function afterEach() { + if (uspResponse.usPrivacy) { + uspSuccess(uspResponse, hookConfig); + } else { + uspError('Unable to get USP consent string.', hookConfig); + } + } + + return { + consentDataCallback: (consentResponse, success) => { + if (success && consentResponse.uspString) { + uspResponse.usPrivacy = consentResponse.uspString; + } + afterEach(); + } + }; + } + + let callbackHandler = handleUspApiResponseCallbacks(); + let uspapiCallbacks = {}; + + // to collect the consent information from the user, we perform a call to USPAPI + // to collect the user's consent choices represented as a string (via getUSPData) + + // the following code also determines where the USPAPI is located and uses the proper workflow to communicate with it: + // - use the USPAPI locator code to see if USP's located in the current window or an ancestor window. This works in friendly or cross domain iframes + // - if USPAPI is not found, the iframe function will call the uspError exit callback to abort the rest of the USPAPI workflow + // - try to call the __uspapi() function directly, otherwise use the postMessage() api + + // find the CMP frame/window + let f = window; + let uspapiFrame; + while (!uspapiFrame) { + try { + if (f.frames['__uspapiLocator']) uspapiFrame = f; + } catch (e) { } + if (f === window.top) break; + f = f.parent; + } + + if (!uspapiFrame) { + return uspError('USP CMP not found.', hookConfig); + } + + try { + // try to call __uspapi directly + uspapiFrame.__uspapi('getUSPData', USPAPI_VERSION, callbackHandler.consentDataCallback); + } catch (e) { + // must not have been accessible, try using postMessage() api + callUspApiWhileInIframe('getUSPData', uspapiFrame, callbackHandler.consentDataCallback); + } + + function callUspApiWhileInIframe(commandName, uspapiFrame, moduleCallback) { + /* Setup up a __uspapi function to do the postMessage and stash the callback. + This function behaves, from the caller's perspective, identicially to the in-frame __uspapi call (although it is not synchronous) */ + window.__uspapi = function (cmd, ver, callback) { + let callId = Math.random() + ''; + let msg = { + __uspapiCall: { + command: cmd, + version: ver, + callId: callId + } + }; + + uspapiCallbacks[callId] = callback; + uspapiFrame.postMessage(msg, '*'); + } + + /** when we get the return message, call the stashed callback */ + window.addEventListener('message', readPostMessageResponse, false); + + // call uspapi + window.__uspapi(commandName, USPAPI_VERSION, uspapiCallback); + + function readPostMessageResponse(event) { + const res = event && event.data && event.data.__uspapiReturn; + if (res && res.callId) { + if (typeof uspapiCallbacks[res.callId] !== 'undefined') { + uspapiCallbacks[res.callId](res.returnValue, res.success); + delete uspapiCallbacks[res.callId]; + } + } + } + + function uspapiCallback(consentObject, success) { + window.removeEventListener('message', readPostMessageResponse, false); + moduleCallback(consentObject, success); + } + } +} + +/** + * If consentManagementUSP module is enabled (ie included in setConfig), this hook function will attempt to fetch the + * user's encoded consent string from the supported USPAPI. Once obtained, the module will store this + * data as part of a uspConsent object which gets transferred to adapterManager's uspDataHandler 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) { + return exitModule(null, hookConfig); + } + + if (!uspCallMap[consentAPI]) { + utils.logWarn(`USP framework (${consentAPI}) is not a supported framework. Aborting consentManagement module and resuming auction.`); + return hookConfig.nextFn.apply(hookConfig.context, hookConfig.args); + } + + uspCallMap[consentAPI].call(this, processUspData, uspapiFailed, hookConfig); + + // only let this code run if module is still active (ie if the callbacks used by USPs haven't already finished) + if (!hookConfig.haveExited) { + if (consentTimeout === 0) { + processUspData(undefined, hookConfig); + } else { + hookConfig.timer = setTimeout(uspapiTimeout.bind(null, hookConfig), consentTimeout); + } + } +} + +/** + * This function checks the consent data provided by USPAPI 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 USPAPI that contains user's consent choices + * @param {object} hookConfig contains module related variables (see comment in requestBidsHook function) + */ +function processUspData(consentObject, hookConfig) { + const valid = !!(consentObject && consentObject.usPrivacy); + if (!valid) { + uspapiFailed(`UPSAPI returned unexpected value during lookup process.`, hookConfig, consentObject); + return; + } + + clearTimeout(hookConfig.timer); + storeUspConsentData(consentObject); + exitModule(null, hookConfig); +} + +/** + * General timeout callback when interacting with USPAPI takes too long. + */ +function uspapiTimeout(hookConfig) { + uspapiFailed('USPAPI workflow exceeded timeout threshold.', hookConfig); +} + +/** + * This function contains the controlled steps to perform when there's a problem with USPAPI. + * @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 uspapiFailed(errMsg, hookConfig, extraArgs) { + clearTimeout(hookConfig.timer); + + exitModule(errMsg, hookConfig, extraArgs); +} + +/** + * Stores USP data locally in module and then invokes uspDataHandler.setConsentData() to make information available in adaptermanger.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 storeUspConsentData(consentObject) { + if (consentObject && consentObject.usPrivacy) { + consentData = consentObject.usPrivacy; + uspDataHandler.setConsentData(consentData); + } +} + +/** + * This function handles the exit logic for the module. + * There are a couple paths in the module's logic to call this function and we only allow 1 of the 2 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 USPAPI being reached. + * While the timeout is the accepted exit and runs first, the USP'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 (USPAPI data is processed normally). + * 2. bad exit but auction still continues (warning message is logged, USPAPI data is undefined and still passed along). + * @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) { + utils.logWarn(errMsg + ' Resuming auction without consent data as per consentManagement config.', extraArgs); + } + nextFn.apply(context, args); + } +} + +/** + * Simply resets the module's consentData variable back to undefined, mainly for testing purposes + */ +export function resetConsentData() { + consentData = undefined; + consentAPI = undefined; + uspDataHandler.setConsentData(null); +} + +/** + * A configuration function that initializes some module variables, as well as add a hook into the requestBids function + * @param {object} config required; consentManagementUSP module config settings; usp (string), timeout (int), allowAuctionWithoutConsent (boolean) + */ +export function setConsentConfig(config) { + config = config.usp; + if (!config || typeof config !== 'object') { + utils.logWarn('consentManagement.usp config not defined, exiting usp consent manager'); + return; + } + if (utils.isStr(config.cmpApi)) { + consentAPI = config.cmpApi; + } else { + consentAPI = DEFAULT_CONSENT_API; + utils.logInfo(`consentManagement.usp config did not specify cmpApi. Using system default setting (${DEFAULT_CONSENT_API}).`); + } + + if (utils.isNumber(config.timeout)) { + consentTimeout = config.timeout; + } else { + consentTimeout = DEFAULT_CONSENT_TIMEOUT; + utils.logInfo(`consentManagement.usp config did not specify timeout. Using system default setting (${DEFAULT_CONSENT_TIMEOUT}).`); + } + + utils.logInfo('USPAPI consentManagement module has been activated...'); + + if (!addedConsentHook) { + $$PREBID_GLOBAL$$.requestBids.before(requestBidsHook, 50); + } + addedConsentHook = true; +} +config.getConfig('consentManagement', config => setConsentConfig(config.consentManagement)); diff --git a/src/adapterManager.js b/src/adapterManager.js index fa7b7cbb58d..3156e49ce8d 100644 --- a/src/adapterManager.js +++ b/src/adapterManager.js @@ -160,6 +160,16 @@ export let gdprDataHandler = { } }; +export let uspDataHandler = { + consentData: null, + setConsentData: function(consentInfo) { + uspDataHandler.consentData = consentInfo; + }, + getConsentData: function() { + return uspDataHandler.consentData; + } +}; + adapterManager.makeBidRequests = function(adUnits, auctionStart, auctionId, cbTimeout, labels) { let bidRequests = []; @@ -261,6 +271,7 @@ adapterManager.makeBidRequests = function(adUnits, auctionStart, auctionId, cbTi if (gdprDataHandler.getConsentData()) { bidRequests.forEach(bidRequest => { bidRequest['gdprConsent'] = gdprDataHandler.getConsentData(); + bidRequest['uspConsent'] = uspDataHandler.getConsentData(); }); } return bidRequests; diff --git a/test/spec/modules/consentManagementUsp_spec.js b/test/spec/modules/consentManagementUsp_spec.js new file mode 100644 index 00000000000..d6e0ef22f83 --- /dev/null +++ b/test/spec/modules/consentManagementUsp_spec.js @@ -0,0 +1,293 @@ +import { + setConsentConfig, + requestBidsHook, + resetConsentData, + consentAPI, + consentTimeout +} from 'modules/consentManagementUsp'; +import * as utils from 'src/utils'; +import { config } from 'src/config'; +import { uspDataHandler } from 'src/adapterManager'; + +let assert = require('chai').assert; +let expect = require('chai').expect; + +function createIFrameMarker() { + var ifr = document.createElement('iframe'); + ifr.width = 0; + ifr.height = 0; + ifr.name = '__uspapiLocator'; + document.body.appendChild(ifr); + return ifr; +} + +describe('consentManagement', function () { + describe('setConsentConfig tests:', function () { + describe('empty setConsentConfig value', function () { + beforeEach(function () { + sinon.stub(utils, 'logInfo'); + sinon.stub(utils, 'logWarn'); + }); + + afterEach(function () { + utils.logInfo.restore(); + utils.logWarn.restore(); + config.resetConfig(); + resetConsentData(); + }); + + it('should not run if no config', function () { + setConsentConfig({}); + expect(consentAPI).to.be.undefined; + expect(consentTimeout).to.be.undefined; + sinon.assert.callCount(utils.logWarn, 1); + }); + + it('should use system default values', function () { + setConsentConfig({usp: {}}); + expect(consentAPI).to.be.equal('iab'); + expect(consentTimeout).to.be.equal(50); + sinon.assert.callCount(utils.logInfo, 3); + }); + + it('should exit the consent manager if config.usp is not an object', function() { + setConsentConfig({}); + expect(consentAPI).to.be.undefined; + sinon.assert.calledOnce(utils.logWarn); + sinon.assert.notCalled(utils.logInfo); + }); + + it('should exit the consent manager if only config.gdpr is an object', function() { + setConsentConfig({ gdpr: { cmpApi: 'iab' } }); + expect(consentAPI).to.be.undefined; + sinon.assert.calledOnce(utils.logWarn); + sinon.assert.notCalled(utils.logInfo); + }); + }); + + describe('valid setConsentConfig value', function () { + afterEach(function () { + config.resetConfig(); + $$PREBID_GLOBAL$$.requestBids.removeAll(); + }); + + it('results in all user settings overriding system defaults', function () { + let allConfig = { + usp: { + cmpApi: 'daa', + timeout: 7500 + } + }; + + setConsentConfig(allConfig); + expect(consentAPI).to.be.equal('daa'); + expect(consentTimeout).to.be.equal(7500); + }); + }); + }); + + describe('requestBidsHook tests:', function () { + let goodConfig = { + usp: { + cmpApi: 'iab', + timeout: 7500 + } + }; + + let noConfig = {}; + + let didHookReturn; + + afterEach(function () { + uspDataHandler.consentData = null; + resetConsentData(); + }); + + describe('error checks:', function () { + beforeEach(function () { + didHookReturn = false; + sinon.stub(utils, 'logWarn'); + sinon.stub(utils, 'logError'); + }); + + afterEach(function () { + utils.logWarn.restore(); + utils.logError.restore(); + config.resetConfig(); + $$PREBID_GLOBAL$$.requestBids.removeAll(); + resetConsentData(); + }); + + it('should throw a warning and return to hooked function when an unknown USPAPI framework ID is used', function () { + let badCMPConfig = { usp: { cmpApi: 'bad' } }; + setConsentConfig(badCMPConfig); + expect(consentAPI).to.be.equal(badCMPConfig.usp.cmpApi); + requestBidsHook(() => { didHookReturn = true; }, {}); + let consent = uspDataHandler.getConsentData(); + sinon.assert.calledOnce(utils.logWarn); + expect(didHookReturn).to.be.true; + expect(consent).to.be.null; + }); + + it('should throw proper errors when USP config is not found', function () { + setConsentConfig(noConfig); + requestBidsHook(() => { didHookReturn = true; }, {}); + let consent = uspDataHandler.getConsentData(); + // throw 2 warnings; one for no bidsBackHandler and for CMP not being found (this is an error due to gdpr config) + sinon.assert.calledTwice(utils.logWarn); + expect(didHookReturn).to.be.true; + expect(consent).to.be.null; + }); + }); + + describe('already known consentData:', function () { + let uspStub = sinon.stub(); + let ifr = null; + + beforeEach(function () { + didHookReturn = false; + ifr = createIFrameMarker(); + window.__uspapi = function() {}; + }); + + afterEach(function () { + config.resetConfig(); + $$PREBID_GLOBAL$$.requestBids.removeAll(); + uspStub.restore(); + document.body.removeChild(ifr); + delete window.__uspapi; + resetConsentData(); + }); + + it('should bypass CMP and simply use previously stored consentData', function () { + let testConsentData = { + uspString: '1YY' + }; + + uspStub = sinon.stub(window, '__uspapi').callsFake((...args) => { + args[2](testConsentData, true); + }); + + setConsentConfig(goodConfig); + requestBidsHook(() => {}, {}); + uspStub.restore(); + + // reset the stub to ensure it wasn't called during the second round of calls + uspStub = sinon.stub(window, '__uspapi').callsFake((...args) => { + args[2](testConsentData, true); + }); + + requestBidsHook(() => { didHookReturn = true; }, {}); + + let consent = uspDataHandler.getConsentData(); + expect(didHookReturn).to.be.true; + expect(consent).to.equal(testConsentData.uspString); + sinon.assert.notCalled(uspStub); + }); + }); + + describe('USPAPI workflow for iframed page', function () { + let ifr = null; + let stringifyResponse = false; + + beforeEach(function () { + sinon.stub(utils, 'logError'); + sinon.stub(utils, 'logWarn'); + ifr = createIFrameMarker(); + window.addEventListener('message', uspapiMessageHandler, false); + }); + + afterEach(function () { + config.resetConfig(); + $$PREBID_GLOBAL$$.requestBids.removeAll(); + delete window.__uspapi; + utils.logError.restore(); + utils.logWarn.restore(); + resetConsentData(); + document.body.removeChild(ifr); + window.removeEventListener('message', uspapiMessageHandler); + }); + + function uspapiMessageHandler(event) { + if (event && event.data) { + var data = event.data; + if (data.__uspapiCall) { + var callId = data.__uspapiCall.callId; + var response = { + __uspapiReturn: { + callId, + returnValue: { uspString: '1YY' }, + success: true + } + }; + event.source.postMessage(stringifyResponse ? JSON.stringify(response) : response, '*'); + } + } + } + + // Run tests with JSON response and String response + // from CMP window postMessage listener. + testIFramedPage('with/JSON response', false); + // testIFramedPage('with/String response', true); + + function testIFramedPage(testName, messageFormatString) { + it(`should return the consent string from a postmessage + addEventListener response - ${testName}`, (done) => { + stringifyResponse = messageFormatString; + setConsentConfig(goodConfig); + requestBidsHook(() => { + let consent = uspDataHandler.getConsentData(); + sinon.assert.notCalled(utils.logWarn); + sinon.assert.notCalled(utils.logError); + expect(consent).to.equal('1YY'); + done(); + }, {}); + }); + } + }); + + describe('USPAPI workflow for normal pages:', function () { + let uspapiStub = sinon.stub(); + let ifr = null; + + beforeEach(function () { + didHookReturn = false; + ifr = createIFrameMarker(); + sinon.stub(utils, 'logError'); + sinon.stub(utils, 'logWarn'); + window.__uspapi = function() {}; + }); + + afterEach(function () { + config.resetConfig(); + $$PREBID_GLOBAL$$.requestBids.removeAll(); + uspapiStub.restore(); + utils.logError.restore(); + utils.logWarn.restore(); + document.body.removeChild(ifr); + delete window.__uspapi; + resetConsentData(); + }); + + it('performs lookup check and stores consentData for a valid existing user', function () { + let testConsentData = { + uspString: '1NY' + }; + + uspapiStub = sinon.stub(window, '__uspapi').callsFake((...args) => { + args[2](testConsentData, true); + }); + + setConsentConfig(goodConfig); + requestBidsHook(() => { didHookReturn = true; }, {}); + + let consent = uspDataHandler.getConsentData(); + + sinon.assert.notCalled(utils.logWarn); + sinon.assert.notCalled(utils.logError); + + expect(didHookReturn).to.be.true; + expect(consent).to.equal(testConsentData.uspString); + }); + }); + }); +}); diff --git a/test/spec/modules/consentManagement_spec.js b/test/spec/modules/consentManagement_spec.js index 6be96427750..9731164c655 100644 --- a/test/spec/modules/consentManagement_spec.js +++ b/test/spec/modules/consentManagement_spec.js @@ -11,11 +11,14 @@ describe('consentManagement', function () { describe('empty setConsentConfig value', function () { beforeEach(function () { sinon.stub(utils, 'logInfo'); + sinon.stub(utils, 'logWarn'); }); afterEach(function () { utils.logInfo.restore(); + utils.logWarn.restore(); config.resetConfig(); + resetConsentData(); }); it('should use system default values', function () { @@ -25,6 +28,18 @@ describe('consentManagement', function () { expect(allowAuction).to.be.true; sinon.assert.callCount(utils.logInfo, 4); }); + + it('should exit consent manager if config is not an object', function() { + setConsentConfig(''); + expect(userCMP).to.be.undefined; + sinon.assert.calledOnce(utils.logWarn); + }); + + it('should exit consent manager if gdpr not set with new config structure', function() { + setConsentConfig({ usp: { cmpApi: 'iab', timeout: 50 } }); + expect(userCMP).to.be.undefined; + sinon.assert.calledOnce(utils.logWarn); + }); }); describe('valid setConsentConfig value', function () { @@ -32,6 +47,7 @@ describe('consentManagement', function () { config.resetConfig(); $$PREBID_GLOBAL$$.requestBids.removeAll(); }); + it('results in all user settings overriding system defaults', function () { let allConfig = { cmpApi: 'iab', @@ -44,6 +60,57 @@ describe('consentManagement', function () { expect(consentTimeout).to.be.equal(7500); expect(allowAuction).to.be.false; }); + + it('should use new consent manager config structure for gdpr', function() { + setConsentConfig({ + gdpr: { cmpApi: 'daa', timeout: 8700 } + }); + + expect(userCMP).to.be.equal('daa'); + expect(consentTimeout).to.be.equal(8700); + }); + + it('should ignore config.usp and use config.gdpr, with default cmpApi', function() { + setConsentConfig({ + gdpr: { timeout: 5000 }, + usp: { cmpApi: 'daa', timeout: 50 } + }); + + expect(userCMP).to.be.equal('iab'); + expect(consentTimeout).to.be.equal(5000); + }); + + it('should ignore config.usp and use config.gdpr, with default cmpAip and timeout', function() { + setConsentConfig({ + gdpr: {}, + usp: { cmpApi: 'daa', timeout: 50 } + }); + + expect(userCMP).to.be.equal('iab'); + expect(consentTimeout).to.be.equal(10000); + }); + + it('should recognize config.gdpr, with default cmpAip and timeout', function() { + setConsentConfig({ + gdpr: {} + }); + + expect(userCMP).to.be.equal('iab'); + expect(consentTimeout).to.be.equal(10000); + }); + + it('should fallback to old consent manager config object if no config.gdpr', function() { + setConsentConfig({ + cmpApi: 'iab', + timeout: 3333, + allowAuctionWithoutConsent: false, + gdpr: false + }); + + expect(userCMP).to.be.equal('iab'); + expect(consentTimeout).to.be.equal(3333); + expect(allowAuction).to.be.equal(false); + }); }); describe('static consent string setConsentConfig value', () => {