diff --git a/modules/.submodules.json b/modules/.submodules.json index 3a00fb6e2e8..816a7ba6d16 100644 --- a/modules/.submodules.json +++ b/modules/.submodules.json @@ -10,6 +10,7 @@ "adriverIdSystem", "adtelligentIdSystem", "amxIdSystem", + "anonymisedIdSystem", "ceeIdSystem", "connectIdSystem", "criteoIdSystem", diff --git a/modules/anonymisedIdSystem.d.ts b/modules/anonymisedIdSystem.d.ts new file mode 100644 index 00000000000..4d9383fee4c --- /dev/null +++ b/modules/anonymisedIdSystem.d.ts @@ -0,0 +1,20 @@ +// the augmentation in this file only applies where the spec is part of the program +import type {} from './userId/spec.js'; + +export type AnonymisedIdSystemModuleName = 'anonymisedId'; + +declare module './userId/spec' { + interface UserId { + anonymisedId: string; + } + + interface ProvidersToId { + anonymisedId: 'anonymisedId'; + } + + interface ProviderParams { + anonymisedId: never; + } +} + +export {}; diff --git a/modules/anonymisedIdSystem.js b/modules/anonymisedIdSystem.js new file mode 100644 index 00000000000..5c3b3c5f916 --- /dev/null +++ b/modules/anonymisedIdSystem.js @@ -0,0 +1,141 @@ +/** + * This module adds the Anonymised ID to the User ID module + * The {@link module:modules/userId} module is required + * @module modules/anonymisedIdSystem + * @requires module:modules/userId + */ +import { submodule } from '../src/hook.js'; +import { getStorageManager } from '../src/storageManager.js'; +import { MODULE_TYPE_UID } from '../src/activities/modules.js'; +import { logInfo, logWarn } from '../src/utils.js'; + +const MODULE_NAME = 'anonymisedId'; +const GVLID = 1116; +const EID_SOURCE = 'anonymised.io'; +const LOG_PREFIX = 'User ID - anonymisedId submodule: '; + +/** + * Local storage key holding the CUID. It is written by the Anonymised Marketing Tag when the user + * signs in, and removed by it on sign-out or when consent is withdrawn. This module only reads it. + */ +export const STORAGE_KEY = 'anon-cuid'; + +/** + * Generous upper bound on the identifier length, to keep a corrupted value from bloating every + * bid request. + */ +export const MAX_ID_LENGTH = 100; + +export const storage = getStorageManager({ moduleType: MODULE_TYPE_UID, moduleName: MODULE_NAME }); + +const STORAGE_CONFIG_WARNING = `${LOG_PREFIX}no ID will be provided: this module must be configured without "storage". ` + + 'The Anonymised Marketing Tag owns this ID and removes it on sign-out and on consent withdrawal; ' + + 'a copy cached by Prebid.js would outlive that removal and keep sending the ID of a signed-out user.'; + +/** + * A publisher who configures `storage` gets no ID at all, rather than one that Prebid.js may cache + * past the point where the Marketing Tag has removed it. Both entry points have to refuse: + * `getId` so nothing is ever written to the publisher's store, and `decode` because the User ID + * module skips `getId` entirely while a cached value is still fresh, decoding that copy instead. + * @param {Object} [config] this submodule's publisher configuration + * @returns {boolean} + */ +function usesUnsupportedStorage(config) { + if (!config?.storage) { + return false; + } + logWarn(STORAGE_CONFIG_WARNING); + return true; +} + +/** + * Characters that cannot occur in a raw identifier, and whose presence means the value was + * serialised rather than written as-is - a JSON object, array, or quoted scalar. Passing such a + * value on would send bidders an ID that matches nothing. + */ +const ENCODED_VALUE_CHARS = /[\s{}[\]"']/; + +/** + * The Marketing Tag writes the CUID as a plain string. Validation is deliberately loose - it + * rejects the values that would be harmful to pass on (empty, serialised, or implausibly long) + * without pinning the identifier's format, which is owned by the tag and can change on a much + * faster release cycle than this module. + * @param {*} value + * @returns {boolean} + */ +export function isValidId(value) { + return typeof value === 'string' && + value.length > 0 && + value.length <= MAX_ID_LENGTH && + !ENCODED_VALUE_CHARS.test(value); +} + +export const anonymisedIdSubmodule = { + /** + * used to link submodule with config + * @type {string} + */ + name: MODULE_NAME, + + /** + * IAB Global Vendor List ID + * @type {number} + */ + gvlid: GVLID, + + /** + * Read the CUID that the Anonymised Marketing Tag stored on this domain. This is a synchronous + * read with no network call: when the tag has not written an ID yet - because it is not installed, + * or the user is not signed in - there is simply no ID for this page view. + * @function + * @param {Object} [config] this submodule's publisher configuration + * @returns {{id: string} | undefined} + */ + getId(config) { + if (usesUnsupportedStorage(config)) { + return undefined; + } + + const stored = storage.getDataFromLocalStorage(STORAGE_KEY); + const cuid = typeof stored === 'string' ? stored.trim() : null; + + if (!cuid) { + // No ID is the expected state for a signed-out user, so this is not a warning: it is also + // what a reader sees when device access is denied, and it is most of the traffic. + logInfo(`${LOG_PREFIX}no ID in localStorage["${STORAGE_KEY}"] - the user is signed out, the Anonymised Marketing Tag is not installed on this page, or device access is not permitted`); + return undefined; + } + + if (!isValidId(cuid)) { + logWarn(`${LOG_PREFIX}ignoring malformed value in localStorage["${STORAGE_KEY}"]`); + return undefined; + } + + logInfo(`${LOG_PREFIX}ID found`); + return { id: cuid }; + }, + + /** + * decode the stored id value for passing to bid requests + * @function + * @param {string} value + * @param {Object} [config] this submodule's publisher configuration + * @returns {{anonymisedId: string} | undefined} + */ + decode(value, config) { + if (usesUnsupportedStorage(config)) { + return undefined; + } + + return isValidId(value) ? { [MODULE_NAME]: value } : undefined; + }, + + eids: { + [MODULE_NAME]: { + source: EID_SOURCE, + atype: 1 + } + } +}; + +submodule('userId', anonymisedIdSubmodule); diff --git a/modules/anonymisedIdSystem.md b/modules/anonymisedIdSystem.md new file mode 100644 index 00000000000..93ddee8fa57 --- /dev/null +++ b/modules/anonymisedIdSystem.md @@ -0,0 +1,104 @@ +# Overview + +Module Name: anonymisedIdSystem +Module Type: UserID Module +Maintainer: support@anonymised.io + +# Description + +Anonymised is a data anonymization technology for privacy-preserving advertising. + +The Anonymised User ID submodule exposes the CUID - the identifier that the +[Anonymised Marketing Tag](https://support.anonymised.io/integrate/marketing-tag?t=LPukVCXzSIcRoal5jggyeg) +assigns when a user signs in - to bid adapters as an OpenRTB Extended ID under the source +`anonymised.io`. + +The submodule performs no network calls. It reads the identifier that the Marketing Tag has already +stored on the publisher's own domain, in `localStorage` under the key `anon-cuid`, and passes it to +the bid stream. When the Marketing Tag is not installed, or the user is not signed in, no ID is read +and no EID is added. + +### Prerequisite + +The Anonymised Marketing Tag must be installed on the page. This submodule does not load it. The tag +can be installed [natively](https://support.anonymised.io/integrate/install-the-anonymised-tag-natively?t=LPukVCXzSIcRoal5jggyeg) +or through the [`anonymisedRtdProvider`](anonymisedRtdProvider.md) module's `tagConfig` parameter. + +# Building Prebid with Anonymised ID support + +```bash +gulp build --modules=userId,anonymisedIdSystem +``` + +# Configuration + +```javascript +pbjs.setConfig({ + userSync: { + userIds: [{ + name: 'anonymisedId' + }] + } +}); +``` + +| Param under userSync.userIds[] | Scope | Type | Description | Example | +| --- | --- | --- | --- | --- | +| name | Required | String | The name of this module. | `'anonymisedId'` | + +The submodule takes no `params`. + +### Do not configure `storage` + +This submodule manages the identifier itself and must be configured **without** a `storage` object. + +The Marketing Tag is the single source of truth for the CUID: it writes the identifier on sign-in and +removes it on sign-out and on consent withdrawal. If Prebid.js were allowed to keep its own copy, that +copy would outlive the removal and the submodule would keep sending a stale identifier to bidders +until Prebid's own expiry elapsed. Reading the value fresh on every initialization makes removal take +effect immediately. + +If a `storage` object is configured, the submodule logs a warning and provides **no** ID at all, +rather than one Prebid.js may cache beyond the Marketing Tag's removal of it. + +### Do not set `userSync.ppid` to `anonymised.io` + +The Marketing Tag sets the Google Ad Manager Publisher Provided ID itself, as part of its SignalLift +feature. Pointing `userSync.ppid` at `anonymised.io` makes Prebid.js set the PPID as well, which +produces two problems: + +- Prebid.js strips non-alphanumeric characters from an ID before setting it as the PPID, while the + Marketing Tag sends the identifier unmodified. The same user would be represented by two different + PPIDs depending on which code path ran, splitting Google Ad Manager audiences and reporting. +- The Marketing Tag applies its own logic when deciding whether a PPID should be set at all. Prebid.js + is not aware of that logic and would bypass it. + +The division is: the Marketing Tag owns the identifier sent to **Google Ad Manager**; this submodule +owns the identifier sent to **bidders**. + +### Single-page applications + +`getId` is called when the User ID module initializes and is not re-run for subsequent auctions. If a +user signs in after that point, call `pbjs.refreshUserIds({ submoduleNames: ['anonymisedId'] })` to +pick up the new identifier. Always pass `submoduleNames` - an unscoped refresh re-initializes every +configured ID submodule, including those that make network requests. + +### Subdomains + +The identifier is read from `localStorage`, which is scoped to a single origin. A publisher serving +the same user from more than one subdomain will have an identifier available on each subdomain only +after the Marketing Tag has run there. + +### Data deletion + +Deletion requests are handled by the Marketing Tag, which owns the user's session and every +identifier derived from it. This submodule stores nothing of its own and therefore implements no +`onDataDeletionRequest` callback. + +### Vendor and storage disclosure + +The submodule declares GVL ID `1116`. Its first-party storage use is disclosed at +[https://cdn1.anonymised.io/deviceStorage.json](https://cdn1.anonymised.io/deviceStorage.json). + +For any questions or assistance with integrating Prebid, `anonymisedIdSystem`, or the Anonymised +Marketing Tag, please contact an [Anonymised representative](mailto:support@anonymised.io). diff --git a/modules/userId/eids.md b/modules/userId/eids.md index c19002d9b3a..ddc2c066326 100644 --- a/modules/userId/eids.md +++ b/modules/userId/eids.md @@ -26,6 +26,13 @@ userIdAsEids = [ atype: 1 }] }, + { + source: 'anonymised.io', + uids: [{ + id: 'some-random-id-value', + atype: 1 + }] + }, { source: 'utiq.com', uids: [{ diff --git a/modules/userId/userId.md b/modules/userId/userId.md index e538a78b7e7..150542ff592 100644 --- a/modules/userId/userId.md +++ b/modules/userId/userId.md @@ -40,6 +40,9 @@ pbjs.setConfig({ expires: 1, refreshInSeconds: 86400 } + }, { + // the Anonymised Marketing Tag owns this ID; it must be configured without `storage` + name: "anonymisedId" }, { name: "pubCommonId", storage: { diff --git a/test/spec/modules/anonymisedIdSystem_spec.js b/test/spec/modules/anonymisedIdSystem_spec.js new file mode 100644 index 00000000000..627760a8313 --- /dev/null +++ b/test/spec/modules/anonymisedIdSystem_spec.js @@ -0,0 +1,199 @@ +import { anonymisedIdSubmodule, storage, STORAGE_KEY, MAX_ID_LENGTH } from 'modules/anonymisedIdSystem.js'; +import { createEidsArray } from 'modules/userId/eids.js'; +import * as utils from 'src/utils.js'; + +const CUID = '01f6a483-86fa-406b-a7c2-45f6d4a89469'; + +describe('anonymisedId submodule', function () { + let getDataFromLocalStorageStub; + + beforeEach(function () { + getDataFromLocalStorageStub = sinon.stub(storage, 'getDataFromLocalStorage'); + }); + + afterEach(function () { + getDataFromLocalStorageStub.restore(); + }); + + it('is registered with the expected name and GVL ID', function () { + expect(anonymisedIdSubmodule.name).to.equal('anonymisedId'); + expect(anonymisedIdSubmodule.gvlid).to.equal(1116); + }); + + describe('getId()', function () { + it('returns the CUID stored by the Marketing Tag', function () { + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(CUID); + expect(anonymisedIdSubmodule.getId()).to.deep.equal({ id: CUID }); + }); + + it('trims surrounding whitespace', function () { + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(` ${CUID}\n`); + expect(anonymisedIdSubmodule.getId()).to.deep.equal({ id: CUID }); + }); + + it('does not read any key other than anon-cuid', function () { + getDataFromLocalStorageStub.returns(CUID); + anonymisedIdSubmodule.getId(); + expect(getDataFromLocalStorageStub.calledOnceWith(STORAGE_KEY)).to.equal(true); + }); + + [undefined, null, '', ' ', 0, {}].forEach(function (stored) { + it(`returns undefined when localStorage holds ${JSON.stringify(stored)}`, function () { + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(stored); + expect(anonymisedIdSubmodule.getId()).to.equal(undefined); + }); + }); + + it('rejects a JSON blob left by another writer', function () { + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(`{"cuid":"${CUID}"}`); + expect(anonymisedIdSubmodule.getId()).to.equal(undefined); + }); + + it('rejects a JSON-stringified CUID rather than passing on its quotes', function () { + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(JSON.stringify(CUID)); + expect(anonymisedIdSubmodule.getId()).to.equal(undefined); + }); + + it('rejects a JSON array', function () { + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(`["${CUID}"]`); + expect(anonymisedIdSubmodule.getId()).to.equal(undefined); + }); + + it('rejects either bracket on its own, not just a well-formed array', function () { + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(`${CUID}]`); + expect(anonymisedIdSubmodule.getId()).to.equal(undefined); + }); + + it('rejects a value containing whitespace', function () { + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns('not an id'); + expect(anonymisedIdSubmodule.getId()).to.equal(undefined); + }); + + it('rejects a value longer than the maximum length', function () { + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns('a'.repeat(MAX_ID_LENGTH + 1)); + expect(anonymisedIdSubmodule.getId()).to.equal(undefined); + }); + + it('accepts a value at the maximum length', function () { + const id = 'a'.repeat(MAX_ID_LENGTH); + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(id); + expect(anonymisedIdSubmodule.getId()).to.deep.equal({ id }); + }); + + it('does not pin the identifier to a UUID format', function () { + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns('AbC_123.456'); + expect(anonymisedIdSubmodule.getId()).to.deep.equal({ id: 'AbC_123.456' }); + }); + + // Prebid.js caches whatever `getId` returns when `storage` is configured, and that copy would + // outlive the Marketing Tag's removal of the key. Refusing to return an ID is what keeps a + // signed-out user's CUID out of the bid stream; a warning alone would not. + it('provides no ID at all when the publisher configured storage', function () { + const logWarnStub = sinon.stub(utils, 'logWarn'); + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(CUID); + + try { + const id = anonymisedIdSubmodule.getId({ storage: { type: 'html5', name: 'anonymisedId', expires: 30 } }); + expect(id).to.equal(undefined); + expect(logWarnStub.calledWithMatch(/must be configured without "storage"/)).to.equal(true); + } finally { + logWarnStub.restore(); + } + }); + + it('does not read storage at all when the publisher configured storage', function () { + const logWarnStub = sinon.stub(utils, 'logWarn'); + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(CUID); + + try { + anonymisedIdSubmodule.getId({ storage: { type: 'html5', name: 'anonymisedId' } }); + expect(getDataFromLocalStorageStub.called).to.equal(false); + } finally { + logWarnStub.restore(); + } + }); + + it('does not warn about storage when none is configured', function () { + const logWarnStub = sinon.stub(utils, 'logWarn'); + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(CUID); + + try { + anonymisedIdSubmodule.getId({}); + expect(logWarnStub.called).to.equal(false); + } finally { + logWarnStub.restore(); + } + }); + + it('does not warn when the user is simply signed out', function () { + const logWarnStub = sinon.stub(utils, 'logWarn'); + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns(null); + + try { + expect(anonymisedIdSubmodule.getId()).to.equal(undefined); + expect(logWarnStub.called).to.equal(false); + } finally { + logWarnStub.restore(); + } + }); + + it('warns when the stored value is malformed', function () { + const logWarnStub = sinon.stub(utils, 'logWarn'); + getDataFromLocalStorageStub.withArgs(STORAGE_KEY).returns('{"cuid":"x"}'); + + try { + expect(anonymisedIdSubmodule.getId()).to.equal(undefined); + expect(logWarnStub.calledWithMatch(/malformed/)).to.equal(true); + } finally { + logWarnStub.restore(); + } + }); + }); + + describe('decode()', function () { + it('decodes a stored CUID', function () { + expect(anonymisedIdSubmodule.decode(CUID)).to.deep.equal({ anonymisedId: CUID }); + }); + + [undefined, null, '', ' ', 42, {}, { anonymisedId: CUID }].forEach(function (value) { + it(`returns undefined for ${JSON.stringify(value)}`, function () { + expect(anonymisedIdSubmodule.decode(value)).to.equal(undefined); + }); + }); + + // The User ID module skips getId entirely while a cached value is still fresh, and decodes + // that copy instead - so refusing in getId alone would still let a stale cached ID through. + it('refuses a value cached by Prebid.js when storage is configured', function () { + const logWarnStub = sinon.stub(utils, 'logWarn'); + + try { + const decoded = anonymisedIdSubmodule.decode(CUID, { storage: { type: 'html5', name: 'anonymisedId', expires: 30 } }); + expect(decoded).to.equal(undefined); + expect(logWarnStub.calledWithMatch(/must be configured without "storage"/)).to.equal(true); + } finally { + logWarnStub.restore(); + } + }); + + it('decodes normally when no storage is configured', function () { + expect(anonymisedIdSubmodule.decode(CUID, {})).to.deep.equal({ anonymisedId: CUID }); + }); + }); + + describe('eids', function () { + it('produces an anonymised.io EID with atype 1', function () { + const eids = createEidsArray( + { anonymisedId: CUID }, + new Map([['anonymisedId', anonymisedIdSubmodule.eids.anonymisedId]]) + ); + expect(eids).to.deep.equal([{ + source: 'anonymised.io', + uids: [{ id: CUID, atype: 1 }] + }]); + }); + + it('declares atype as a number, as OpenRTB requires', function () { + expect(anonymisedIdSubmodule.eids.anonymisedId.atype).to.be.a('number'); + }); + }); +});