Skip to content

Commit

Permalink
EUID Id Module : add support for client side token generation (#10885)
Browse files Browse the repository at this point in the history
* enable cstg for euid

* test added for euid cstg

* fixed euid cstg test and updated docs
  • Loading branch information
ssundahlTTD authored Jan 17, 2024
1 parent a63e748 commit 2e4b98e
Show file tree
Hide file tree
Showing 6 changed files with 124 additions and 42 deletions.
10 changes: 9 additions & 1 deletion modules/euidIdSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
52 changes: 51 additions & 1 deletion modules/euidIdSystem.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
14 changes: 1 addition & 13 deletions modules/uid2IdSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);

Expand Down
12 changes: 12 additions & 0 deletions modules/uid2IdSystem_shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
}
34 changes: 31 additions & 3 deletions test/spec/modules/euidIdSystem_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,31 +23,45 @@ 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');

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();
hook.ready();
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();
Expand Down Expand Up @@ -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);
});
}
});
44 changes: 20 additions & 24 deletions test/spec/modules/uid2IdSystem_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
})
})

Expand Down

2 comments on commit 2e4b98e

@mikemangas
Copy link

@mikemangas mikemangas commented on 2e4b98e Jan 25, 2024

Choose a reason for hiding this comment

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

@ssundahlTTD hello,

you mention: $ gulp build --modules=uid2IdSystem --disable UID2_CS in the euidIdSystem.md

Isnt this a mistake?

Shouldnt it be $ gulp build --modules=euidIdSysem --disable UID2_CS ?

Best

@ssundahlTTD
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you for pointing this out. I have a PR coming up in a couple of weeks and I'll be sure to get this corrected. Cheers!

Please sign in to comment.