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

EUID Id Module : add support for client side token generation #10885

Merged
merged 3 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think re-using this feature flag is fine - I expect any publisher using CSTG for either module would almost certainly use it for both modules.

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