diff --git a/modules/euidIdSystem.js b/modules/euidIdSystem.js index 6a3a0869c0e..a29da69d6c7 100644 --- a/modules/euidIdSystem.js +++ b/modules/euidIdSystem.js @@ -12,7 +12,7 @@ import {MODULE_TYPE_UID} from '../src/activities/modules.js'; // RE below lint exception: UID2 and EUID are separate modules, but the protocol is the same and shared code makes sense here. // eslint-disable-next-line prebid/validate-imports -import { Uid2GetId, Uid2CodeVersion } from './uid2IdSystem_shared.js'; +import { Uid2GetId, Uid2CodeVersion, extractIdentityFromParams } from './uid2IdSystem_shared.js'; const MODULE_NAME = 'euid'; const MODULE_REVISION = Uid2CodeVersion; @@ -99,6 +99,14 @@ export const euidIdSubmodule = { internalStorage: ADVERTISING_COOKIE }; + if (FEATURES.UID2_CSTG) { + mappedConfig.cstg = { + serverPublicKey: config?.params?.serverPublicKey, + subscriptionId: config?.params?.subscriptionId, + ...extractIdentityFromParams(config?.params ?? {}) + } + } + _logInfo(`EUID configuration loaded and mapped.`, mappedConfig); const result = Uid2GetId(mappedConfig, storage, _logInfo, _logWarn); _logInfo(`EUID getId returned`, result); return result; diff --git a/modules/euidIdSystem.md b/modules/euidIdSystem.md index e3e16bce89d..72e40b8ce7b 100644 --- a/modules/euidIdSystem.md +++ b/modules/euidIdSystem.md @@ -1,9 +1,59 @@ ## EUID User ID Submodule -EUID requires initial tokens to be generated server-side. The EUID module handles storing, providing, and optionally refreshing them. The module can operate in one of two different modes: *Client Refresh* mode or *Server Only* mode. +The EUID module handles storing, providing, and optionally refreshing tokens. While initial tokens traditionally required server-side generation, the introduction of the *Client-Side Token Generation (CSTG)* mode offers publishers the flexibility to generate EUID tokens directly from the module, eliminating this need. Publishers can choose to operate the module in one of three distinct modes: *Client Refresh* mode, *Server Only* mode and *Client-Side Token Generation* mode. *Server Only* mode was originally referred to as *legacy mode*, but it is a popular mode for new integrations where publishers prefer to handle token refresh server-side. +*Client-Side Token Generation* mode is included in EUID module by default. However, it's important to note that this mode is created and made available recently. For publishers who do not intend to use it, you have the option to instruct the build to exclude the code related to this feature: + +``` + $ gulp build --modules=uid2IdSystem --disable UID2_CSTG +``` +If you do plan to use Client-Side Token Generation (CSTG) mode, please consult the EUID Team first as they will provide required configuration values for you to use (see the Client-Side Token Generation (CSTG) mode section below for details) + +**This mode is created and made available recently. Please consult EUID Team first as they will provide required configuration values for you to use.** + +For publishers seeking a purely client-side integration without the complexities of server-side involvement, the CSTG mode is highly recommended. This mode requires the provision of a public key, subscription ID and [directly identifying information (DII)](https://unifiedid.com/docs/ref-info/glossary-uid#gl-dii) - either emails or phone numbers. In the CSTG mode, the module takes on the responsibility of encrypting the DII, generating the EUID token, and handling token refreshes when necessary. + +To configure the module to use this mode, you must: +1. Set `parmas.serverPublicKey` and `params.subscriptionId` (please reach out to the UID2 team to obtain these values) +2. Provide **ONLY ONE DII** by setting **ONLY ONE** of `params.email`/`params.phone`/`params.emailHash`/`params.phoneHash` + +Below is a table that provides guidance on when to use each directly identifying information (DII) parameter, along with information on whether normalization and hashing are required by the publisher for each parameter. + +| DII param | When to use it | Normalization required by publisher? | Hashing required by publisher? | +|------------------|-------------------------------------------------------|--------------------------------------|--------------------------------| +| params.email | When you have users' email address | No | No | +| params.phone | When you have user's phone number | Yes | No | +| params.emailHash | When you have user's hashed, normalized email address | Yes | Yes | +| params.phoneHash | When you have user's hashed, normalized phone number | Yes | Yes | + + +*Note that setting params.email will normalize email addresses, but params.phone requires phone numbers to be normalized.* + +Refer to [Normalization and Encoding](#normalization-and-encoding) for details on email address normalization, SHA-256 hashing and Base64 encoding. + +### CSTG example + +Configuration: +``` +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'euid', + params: { + serverPublicKey: '...server public key...', + subscriptionId: '...subcription id...', + email: 'user@email.com', + //phone: '+0000000', + //emailHash: '...email hash...', + //phoneHash: '...phone hash ...' + } + }] + } +}); +``` + ## Client Refresh mode This is the recommended mode for most scenarios. In this mode, the full response body from the EUID Token Generate or Token Refresh endpoint must be provided to the module. As long as the refresh token remains valid, the module will refresh the advertising token as needed. diff --git a/modules/uid2IdSystem.js b/modules/uid2IdSystem.js index b9b3dfa2380..13c0303e0b3 100644 --- a/modules/uid2IdSystem.js +++ b/modules/uid2IdSystem.js @@ -13,7 +13,7 @@ import {MODULE_TYPE_UID} from '../src/activities/modules.js'; // RE below lint exception: UID2 and EUID are separate modules, but the protocol is the same and shared code makes sense here. // eslint-disable-next-line prebid/validate-imports -import { Uid2GetId, Uid2CodeVersion } from './uid2IdSystem_shared.js'; +import { Uid2GetId, Uid2CodeVersion, extractIdentityFromParams } from './uid2IdSystem_shared.js'; import {UID2_EIDS} from '../libraries/uid2Eids/uid2Eids.js'; const MODULE_NAME = 'uid2'; @@ -34,18 +34,6 @@ function createLogger(logger, prefix) { } } -function extractIdentityFromParams(params) { - const keysToCheck = ['emailHash', 'phoneHash', 'email', 'phone']; - - for (let key of keysToCheck) { - if (params.hasOwnProperty(key)) { - return { [key]: params[key] }; - } - } - - return {}; -} - const _logInfo = createLogger(logInfo, LOG_PRE_FIX); const _logWarn = createLogger(logWarn, LOG_PRE_FIX); diff --git a/modules/uid2IdSystem_shared.js b/modules/uid2IdSystem_shared.js index 29837c5f012..acc440eafc5 100644 --- a/modules/uid2IdSystem_shared.js +++ b/modules/uid2IdSystem_shared.js @@ -755,3 +755,15 @@ export function Uid2GetId(config, prebidStorageManager, _logInfo, _logWarn) { storageManager.storeValue(tokens); return { id: tokens }; } + +export function extractIdentityFromParams(params) { + const keysToCheck = ['emailHash', 'phoneHash', 'email', 'phone']; + + for (let key of keysToCheck) { + if (params.hasOwnProperty(key)) { + return { [key]: params[key] }; + } + } + + return {}; +} diff --git a/test/spec/modules/euidIdSystem_spec.js b/test/spec/modules/euidIdSystem_spec.js index 9e4598bb5f5..98770fa80bc 100644 --- a/test/spec/modules/euidIdSystem_spec.js +++ b/test/spec/modules/euidIdSystem_spec.js @@ -3,6 +3,7 @@ import {config} from 'src/config.js'; import {euidIdSubmodule} from 'modules/euidIdSystem.js'; import 'modules/consentManagement.js'; import 'src/prebid.js'; +import * as utils from 'src/utils.js'; import {apiHelpers, cookieHelpers, runAuction, setGdprApplies} from './uid2IdSystem_helpers.js'; import {hook} from 'src/hook.js'; import {uninstall as uninstallGdprEnforcement} from 'modules/gdprEnforcement.js'; @@ -22,13 +23,18 @@ const auctionDelayMs = 10; const makeEuidIdentityContainer = (token) => ({euid: {id: token}}); const useLocalStorage = true; + const makePrebidConfig = (params = null, extraSettings = {}, debug = false) => ({ userSync: { auctionDelay: auctionDelayMs, userIds: [{name: 'euid', params: {storage: useLocalStorage ? 'localStorage' : 'cookie', ...params}, ...extraSettings}] }, debug }); +const cstgConfigParams = { serverPublicKey: 'UID2-X-L-24B8a/eLYBmRkXA9yPgRZt+ouKbXewG2OPs23+ov3JC8mtYJBCx6AxGwJ4MlwUcguebhdDp2CvzsCgS9ogwwGA==', subscriptionId: 'subscription-id' } +const clientSideGeneratedToken = 'client-side-generated-advertising-token'; + const apiUrl = 'https://prod.euid.eu/v2/token/refresh'; +const cstgApiUrl = 'https://prod.euid.eu/v2/token/client-generate'; const headers = { 'Content-Type': 'application/json' }; -const makeSuccessResponseBody = () => btoa(JSON.stringify({ status: 'success', body: { ...apiHelpers.makeTokenResponse(initialToken), advertising_token: refreshedToken } })); +const makeSuccessResponseBody = (token) => btoa(JSON.stringify({ status: 'success', body: { ...apiHelpers.makeTokenResponse(initialToken), advertising_token: token } })); const expectToken = (bid, token) => expect(bid?.userId ?? {}).to.deep.include(makeEuidIdentityContainer(token)); const expectNoIdentity = (bid) => expect(bid).to.not.haveOwnProperty('userId'); @@ -36,6 +42,7 @@ describe('EUID module', function() { let suiteSandbox, restoreSubtleToUndefined = false; const configureEuidResponse = (httpStatus, response) => server.respondWith('POST', apiUrl, (xhr) => xhr.respond(httpStatus, headers, response)); + const configureEuidCstgResponse = (httpStatus, response) => server.respondWith('POST', cstgApiUrl, (xhr) => xhr.respond(httpStatus, headers, response)); before(function() { uninstallGdprEnforcement(); @@ -43,10 +50,18 @@ describe('EUID module', function() { suiteSandbox = sinon.sandbox.create(); if (typeof window.crypto.subtle === 'undefined') { restoreSubtleToUndefined = true; - window.crypto.subtle = { importKey: () => {}, decrypt: () => {} }; + window.crypto.subtle = { importKey: () => {}, digest: () => {}, decrypt: () => {}, deriveKey: () => {}, encrypt: () => {}, generateKey: () => {}, exportKey: () => {} }; } suiteSandbox.stub(window.crypto.subtle, 'importKey').callsFake(() => Promise.resolve()); + suiteSandbox.stub(window.crypto.subtle, 'digest').callsFake(() => Promise.resolve('hashed_value')); suiteSandbox.stub(window.crypto.subtle, 'decrypt').callsFake((settings, key, data) => Promise.resolve(new Uint8Array([...settings.iv, ...data]))); + suiteSandbox.stub(window.crypto.subtle, 'deriveKey').callsFake(() => Promise.resolve()); + suiteSandbox.stub(window.crypto.subtle, 'exportKey').callsFake(() => Promise.resolve()); + suiteSandbox.stub(window.crypto.subtle, 'encrypt').callsFake(() => Promise.resolve(new ArrayBuffer())); + suiteSandbox.stub(window.crypto.subtle, 'generateKey').callsFake(() => Promise.resolve({ + privateKey: {}, + publicKey: {} + })); }); after(function() { suiteSandbox.restore(); @@ -113,10 +128,23 @@ describe('EUID module', function() { it('When an expired token is provided and the API responds in time, the refreshed token is provided to the auction.', async function() { setGdprApplies(true); const euidToken = apiHelpers.makeTokenResponse(initialToken, true, true); - configureEuidResponse(200, makeSuccessResponseBody()); + configureEuidResponse(200, makeSuccessResponseBody(refreshedToken)); config.setConfig(makePrebidConfig({euidToken})); apiHelpers.respondAfterDelay(1, server); const bid = await runAuction(); expectToken(bid, refreshedToken); }); + + if (FEATURES.UID2_CSTG) { + it('Should use client side generated EUID token in the auction.', async function() { + setGdprApplies(true); + const euidToken = apiHelpers.makeTokenResponse(initialToken, true, true); + configureEuidCstgResponse(200, makeSuccessResponseBody(clientSideGeneratedToken)); + config.setConfig(makePrebidConfig({ euidToken, ...cstgConfigParams, email: 'test@test.com' })); + apiHelpers.respondAfterDelay(1, server); + + const bid = await runAuction(); + expectToken(bid, clientSideGeneratedToken); + }); + } }); diff --git a/test/spec/modules/uid2IdSystem_spec.js b/test/spec/modules/uid2IdSystem_spec.js index 8e3728704c7..901e0c57e32 100644 --- a/test/spec/modules/uid2IdSystem_spec.js +++ b/test/spec/modules/uid2IdSystem_spec.js @@ -476,37 +476,33 @@ describe(`UID2 module`, function () { }) describe('When the storedToken is expired and can be refreshed ', function() { - it('it should calls refresh API', function() { - testApiSuccessAndFailure(async function(apiSucceeds) { - const refreshedIdentity = apiHelpers.makeTokenResponse(refreshedToken, true, true); - const moduleCookie = {originalIdentity: makeOriginalIdentity('test@test.com'), latestToken: refreshedIdentity}; - coreStorage.setCookie(moduleCookieName, JSON.stringify(moduleCookie), cookieHelpers.getFutureCookieExpiry()); - config.setConfig(makePrebidConfig({ ...cstgConfigParams, email: 'test@test.com' })); - apiHelpers.respondAfterDelay(auctionDelayMs / 10, server); + testApiSuccessAndFailure(async function(apiSucceeds) { + const refreshedIdentity = apiHelpers.makeTokenResponse(refreshedToken, true, true); + const moduleCookie = {originalIdentity: makeOriginalIdentity('test@test.com'), latestToken: refreshedIdentity}; + coreStorage.setCookie(moduleCookieName, JSON.stringify(moduleCookie), cookieHelpers.getFutureCookieExpiry()); + config.setConfig(makePrebidConfig({ ...cstgConfigParams, email: 'test@test.com' })); + apiHelpers.respondAfterDelay(auctionDelayMs / 10, server); - const bid = await runAuction(); + const bid = await runAuction(); - if (apiSucceeds) expectToken(bid, refreshedToken); - else expectNoIdentity(bid); - }, refreshApiUrl, 'it should use refreshed token in the auction', 'the auction should have no uid2'); - }); + if (apiSucceeds) expectToken(bid, refreshedToken); + else expectNoIdentity(bid); + }, refreshApiUrl, 'it should use refreshed token in the auction', 'the auction should have no uid2'); }) describe('When the storedToken is expired for refresh', function() { - it('it should calls CSTG API and not use the stored token', function() { - testApiSuccessAndFailure(async function(apiSucceeds) { - const refreshedIdentity = apiHelpers.makeTokenResponse(refreshedToken, true, true, true); - const moduleCookie = {originalIdentity: makeOriginalIdentity('test@test.com'), latestToken: refreshedIdentity}; - coreStorage.setCookie(moduleCookieName, JSON.stringify(moduleCookie), cookieHelpers.getFutureCookieExpiry()); - config.setConfig(makePrebidConfig({ ...cstgConfigParams, email: 'test@test.com' })); - apiHelpers.respondAfterDelay(auctionDelayMs / 10, server); + testApiSuccessAndFailure(async function(apiSucceeds) { + const refreshedIdentity = apiHelpers.makeTokenResponse(refreshedToken, true, true, true); + const moduleCookie = {originalIdentity: makeOriginalIdentity('test@test.com'), latestToken: refreshedIdentity}; + coreStorage.setCookie(moduleCookieName, JSON.stringify(moduleCookie), cookieHelpers.getFutureCookieExpiry()); + config.setConfig(makePrebidConfig({ ...cstgConfigParams, email: 'test@test.com' })); + apiHelpers.respondAfterDelay(auctionDelayMs / 10, server); - const bid = await runAuction(); + const bid = await runAuction(); - if (apiSucceeds) expectToken(bid, clientSideGeneratedToken); - else expectNoIdentity(bid); - }, cstgApiUrl, 'it should use generated token in the auction', 'the auction should have no uid2', false, clientSideGeneratedToken); - }); + if (apiSucceeds) expectToken(bid, clientSideGeneratedToken); + else expectNoIdentity(bid); + }, cstgApiUrl, 'it should use generated token in the auction', 'the auction should have no uid2', false, clientSideGeneratedToken); }) })