Skip to content

Commit 90ffb1c

Browse files
Merge pull request #68 from IABTechLab/llp-uid2-2501-add-optout-support
Add support for optouts to refresh and CSTG calls.
2 parents 8701400 + e11e58a commit 90ffb1c

11 files changed

+203
-41
lines changed

src/Uid2Identity.ts

+8
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,11 @@ export function isValidIdentity(identity: Uid2Identity | unknown): identity is U
2525
'refresh_expires' in identity
2626
);
2727
}
28+
export interface OptoutIdentity extends Pick<Uid2Identity, 'refresh_expires' | 'identity_expires'> {
29+
status: 'optout';
30+
}
31+
export function isOptoutIdentity(identity: OptoutIdentity | unknown): identity is OptoutIdentity {
32+
if (identity === null || typeof identity !== 'object') return false;
33+
const maybeIdentity = identity as OptoutIdentity;
34+
return maybeIdentity.status === 'optout';
35+
}

src/integrationTests/autoRefresh.test.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ testCookieAndLocalStorage(() => {
172172
}
173173
});
174174
test('getAdvertisingTokenPromise should reject', () => {
175-
expect(exception).toEqual(new Error('UID2 SDK aborted.'));
175+
expect(exception).toEqual(new Error('No identity available.'));
176176
});
177177
test('should invoke the callback', () => {
178178
expect(callback).toHaveBeenNthCalledWith(
@@ -185,7 +185,7 @@ testCookieAndLocalStorage(() => {
185185
);
186186
});
187187
test('should clear value', () => {
188-
expect(getUid2(useCookie)).toBeNull();
188+
expect(getUid2(useCookie)).toMatchObject({ status: 'optout' });
189189
});
190190
test('should not set refresh timer', () => {
191191
expect(setTimeout).not.toHaveBeenCalled();
@@ -425,7 +425,7 @@ testCookieAndLocalStorage(() => {
425425
}
426426
});
427427
test('getAdvertisingTokenPromise should reject', () => {
428-
expect(exception).toEqual(new Error('UID2 SDK aborted.'));
428+
expect(exception).toEqual(new Error('No identity available.'));
429429
});
430430
test('should invoke the callback', () => {
431431
expect(callback).toHaveBeenNthCalledWith(
@@ -437,8 +437,8 @@ testCookieAndLocalStorage(() => {
437437
})
438438
);
439439
});
440-
test('should clear value', () => {
441-
expect(getUid2(useCookie)).toBeNull();
440+
test('should store optout', () => {
441+
expect(getUid2(useCookie)).toMatchObject({ status: 'optout' });
442442
});
443443
test('should not set refresh timer', () => {
444444
expect(setTimeout).not.toHaveBeenCalled();

src/integrationTests/basic.test.ts

+49-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globa
22

33
import * as mocks from '../mocks';
44
import { sdkWindow, UID2 } from '../uid2Sdk';
5+
import { EventType } from '../uid2CallbackManager';
56

67
let callback: any;
78
let uid2: UID2;
@@ -335,6 +336,27 @@ testCookieAndLocalStorage(() => {
335336
expect(xhrMock.send).toHaveBeenLastCalledWith(identity.refresh_token);
336337
xhrMock.onreadystatechange();
337338
expect(cryptoMock.subtle.importKey).toHaveBeenCalledTimes(0);
339+
mocks.resetCrypto(sdkWindow);
340+
});
341+
});
342+
describe('when opted out identity is stored', () => {
343+
let callback: ReturnType<typeof jest.fn>;
344+
beforeEach(() => {
345+
const optedOutIdentity = {
346+
status: 'optout',
347+
identity_expires: Date.now() + 10000,
348+
refresh_expires: Date.now() + 10000,
349+
};
350+
callback = jest.fn();
351+
uid2.callbacks.push(callback);
352+
setUid2(optedOutIdentity, useCookie);
353+
uid2.init({ useCookie });
354+
});
355+
test('hasOptedOut should be true', () => {
356+
expect(uid2.hasOptedOut()).toBe(true);
357+
});
358+
test('the callback should be called with an opted out event', () => {
359+
expect(callback).toBeCalledWith(EventType.OptoutReceived, { identity: null });
338360
});
339361
});
340362
});
@@ -457,6 +479,10 @@ testCookieAndLocalStorage(() => {
457479
identity: originalIdentity,
458480
useCookie: useCookie,
459481
});
482+
let cryptoMock = new mocks.CryptoMock(sdkWindow);
483+
});
484+
afterEach(() => {
485+
mocks.resetCrypto(sdkWindow);
460486
});
461487

462488
describe('when token refresh succeeds', () => {
@@ -529,8 +555,8 @@ testCookieAndLocalStorage(() => {
529555
})
530556
);
531557
});
532-
test('should not set cookie', () => {
533-
expect(getUid2(useCookie)).toBeNull();
558+
test('should set cookie to optout', () => {
559+
expect(getUid2(useCookie)).toMatchObject({ status: 'optout' });
534560
});
535561
test('should not set refresh timer', () => {
536562
expect(setTimeout).not.toHaveBeenCalled();
@@ -729,6 +755,10 @@ testCookieAndLocalStorage(() => {
729755
identity: originalIdentity,
730756
useCookie: useCookie,
731757
});
758+
let cryptoMock = new mocks.CryptoMock(sdkWindow);
759+
});
760+
afterEach(() => {
761+
mocks.resetCrypto(sdkWindow);
732762
});
733763

734764
describe('when token refresh succeeds', () => {
@@ -760,8 +790,15 @@ testCookieAndLocalStorage(() => {
760790
});
761791

762792
describe('when token refresh returns optout', () => {
793+
let handler: ReturnType<typeof jest.fn>;
763794
beforeEach(() => {
764-
xhrMock.responseText = btoa(JSON.stringify({ status: 'optout' }));
795+
handler = jest.fn();
796+
uid2.callbacks.push(handler);
797+
xhrMock.responseText = btoa(
798+
JSON.stringify({
799+
status: 'optout',
800+
})
801+
);
765802
xhrMock.onreadystatechange(new Event(''));
766803
});
767804

@@ -774,8 +811,15 @@ testCookieAndLocalStorage(() => {
774811
})
775812
);
776813
});
777-
test('should not set cookie', () => {
778-
expect(getUid2(useCookie)).toBeNull();
814+
test('should invoke the callback with no identity', () => {
815+
expect(handler).toBeCalledWith(EventType.InitCompleted, { identity: null });
816+
});
817+
test('should invoke the callback with an optout event', () => {
818+
expect(handler).toBeCalledWith(EventType.OptoutReceived, { identity: null });
819+
});
820+
test('should store the optout', () => {
821+
const identity = getUid2(useCookie);
822+
expect(identity).toMatchObject({ status: 'optout' });
779823
});
780824
test('should not set refresh timer', () => {
781825
expect(setTimeout).not.toHaveBeenCalled();

src/integrationTests/clientSideTokenGeneration.test.ts

+38
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,44 @@ describe('Client-side token generation Tests', () => {
164164
(expect(uid2) as any).toBeInUnavailableState();
165165
});
166166
});
167+
168+
describe('when optout response is received', () => {
169+
beforeEach(() => {
170+
xhrMock.send.mockImplementationOnce((body: string) => {
171+
const requestBody = JSON.parse(body);
172+
xhrMock.sendEncryptedCSTGResponse(
173+
{
174+
clientPublicKey: base64ToBytes(requestBody.public_key),
175+
serverPrivateKey: serverKeyPair.privateKey,
176+
},
177+
{ status: 'optout' }
178+
);
179+
});
180+
});
181+
test('UID2 should not be available', async () => {
182+
await scenario.setIdentity(serverPublicKey);
183+
(expect(uid2) as any).toBeInUnavailableState();
184+
});
185+
186+
test('The callback should be called with no identity', (done) => {
187+
uid2.callbacks.push((eventType, payload) => {
188+
if (eventType === EventType.IdentityUpdated) {
189+
expect(payload.identity).toBeNull();
190+
done();
191+
}
192+
});
193+
scenario.setIdentity(serverPublicKey);
194+
});
195+
196+
test('The callback should be called with an optout event', (done) => {
197+
uid2.callbacks.push((eventType, payload) => {
198+
if (eventType === EventType.OptoutReceived) {
199+
done();
200+
}
201+
});
202+
scenario.setIdentity(serverPublicKey);
203+
});
204+
});
167205
});
168206
});
169207
});

src/sdkBase.ts

+51-15
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { version } from '../package.json';
2-
import { Uid2Identity } from './Uid2Identity';
2+
import { OptoutIdentity, Uid2Identity, isOptoutIdentity } from './Uid2Identity';
33
import { IdentityStatus, notifyInitCallback } from './Uid2InitCallbacks';
44
import { Uid2Options, isUID2OptionsOrThrow } from './Uid2Options';
55
import { Logger, MakeLogger } from './sdk/logger';
@@ -56,7 +56,7 @@ export abstract class UID2SdkBase {
5656
// State
5757
private _product: ProductDetails;
5858
private _opts: Uid2Options = {};
59-
private _identity: Uid2Identity | null | undefined;
59+
private _identity: Uid2Identity | OptoutIdentity | null | undefined;
6060
private _initComplete = false;
6161

6262
// Sets up nearly everything, but does not run SdkLoaded callbacks - derived classes must run them.
@@ -112,17 +112,23 @@ export abstract class UID2SdkBase {
112112
await this.callCstgAndSetIdentity({ emailHash: emailHash }, opts);
113113
}
114114

115-
public setIdentity(identity: Uid2Identity) {
115+
public setIdentity(identity: Uid2Identity | OptoutIdentity) {
116116
if (this._apiClient) this._apiClient.abortActiveRequests();
117117
const validatedIdentity = this.validateAndSetIdentity(identity);
118118
if (validatedIdentity) {
119-
this.triggerRefreshOrSetTimer(validatedIdentity);
119+
if (isOptoutIdentity(validatedIdentity)) {
120+
this._callbackManager.runCallbacks(EventType.OptoutReceived, {});
121+
} else {
122+
this.triggerRefreshOrSetTimer(validatedIdentity);
123+
}
120124
this._callbackManager.runCallbacks(EventType.IdentityUpdated, {});
121125
}
122126
}
123127

124128
public getIdentity(): Uid2Identity | null {
125-
return this._identity && !this.temporarilyUnavailable() ? this._identity : null;
129+
return this._identity && !this.temporarilyUnavailable() && !isOptoutIdentity(this._identity)
130+
? this._identity
131+
: null;
126132
}
127133
// When the SDK has been initialized, this function should return the token
128134
// from the most recent refresh request, if there is a request, wait for the
@@ -144,6 +150,11 @@ export abstract class UID2SdkBase {
144150
return !(this.isLoggedIn() || this._apiClient?.hasActiveRequests());
145151
}
146152

153+
public hasOptedOut() {
154+
if (!this._initComplete) return undefined;
155+
return isOptoutIdentity(this._identity);
156+
}
157+
147158
public disconnect() {
148159
this.abort(`${this._product.name} SDK disconnected.`);
149160
// Note: This silently fails to clear the cookie if init hasn't been called and a cookieDomain is used!
@@ -196,9 +207,11 @@ export abstract class UID2SdkBase {
196207
identity = this._storageManager.loadIdentityWithFallback();
197208
}
198209
const validatedIdentity = this.validateAndSetIdentity(identity);
199-
if (validatedIdentity) this.triggerRefreshOrSetTimer(validatedIdentity);
210+
if (validatedIdentity && !isOptoutIdentity(validatedIdentity))
211+
this.triggerRefreshOrSetTimer(validatedIdentity);
200212
this._initComplete = true;
201213
this._callbackManager?.runCallbacks(EventType.InitCompleted, {});
214+
if (this.hasOptedOut()) this._callbackManager.runCallbacks(EventType.OptoutReceived, {});
202215
}
203216

204217
private isLoggedIn() {
@@ -216,7 +229,7 @@ export abstract class UID2SdkBase {
216229
return false;
217230
}
218231

219-
private getIdentityStatus(identity: Uid2Identity | null):
232+
private getIdentityStatus(identity: Uid2Identity | OptoutIdentity | null):
220233
| {
221234
valid: true;
222235
identity: Uid2Identity;
@@ -227,7 +240,7 @@ export abstract class UID2SdkBase {
227240
valid: false;
228241
errorMessage: string;
229242
status: IdentityStatus;
230-
identity: null;
243+
identity: OptoutIdentity | null;
231244
} {
232245
if (!identity) {
233246
return {
@@ -237,6 +250,14 @@ export abstract class UID2SdkBase {
237250
identity: null,
238251
};
239252
}
253+
if (isOptoutIdentity(identity)) {
254+
return {
255+
valid: false,
256+
errorMessage: 'User has opted out',
257+
status: IdentityStatus.OPTOUT,
258+
identity: identity,
259+
};
260+
}
240261
if (!identity.advertising_token) {
241262
return {
242263
valid: false,
@@ -285,21 +306,25 @@ export abstract class UID2SdkBase {
285306
}
286307

287308
private validateAndSetIdentity(
288-
identity: Uid2Identity | null,
309+
identity: Uid2Identity | OptoutIdentity | null,
289310
status?: IdentityStatus,
290311
statusText?: string
291-
): Uid2Identity | null {
312+
): Uid2Identity | OptoutIdentity | null {
292313
if (!this._storageManager) throw new Error('Cannot set identity before calling init.');
293314
const validity = this.getIdentityStatus(identity);
294315
if (
316+
validity.valid &&
295317
validity.identity &&
318+
!isOptoutIdentity(this._identity) &&
296319
validity.identity?.advertising_token === this._identity?.advertising_token
297320
)
298321
return validity.identity;
299322

300323
this._identity = validity.identity;
301-
if (validity.identity) {
302-
this._storageManager.setValue(validity.identity);
324+
if (validity.valid && validity.identity) {
325+
this._storageManager.setIdentity(validity.identity);
326+
} else if (validity.status === IdentityStatus.OPTOUT || status === IdentityStatus.OPTOUT) {
327+
this._storageManager.setOptout();
303328
} else {
304329
this.abort();
305330
this._storageManager.removeValues();
@@ -334,7 +359,8 @@ export abstract class UID2SdkBase {
334359
const validatedIdentity = this.validateAndSetIdentity(
335360
this._storageManager?.loadIdentity() ?? null
336361
);
337-
if (validatedIdentity) this.triggerRefreshOrSetTimer(validatedIdentity);
362+
if (validatedIdentity && !isOptoutIdentity(validatedIdentity))
363+
this.triggerRefreshOrSetTimer(validatedIdentity);
338364
this._refreshTimerId = null;
339365
}, timeout);
340366
}
@@ -358,6 +384,7 @@ export abstract class UID2SdkBase {
358384
break;
359385
case 'optout':
360386
this.validateAndSetIdentity(null, IdentityStatus.OPTOUT, 'User opted out');
387+
this._callbackManager.runCallbacks(EventType.OptoutReceived, {});
361388
break;
362389
case 'expired_token':
363390
this.validateAndSetIdentity(
@@ -387,8 +414,17 @@ export abstract class UID2SdkBase {
387414
opts: ClientSideIdentityOptions
388415
) {
389416
const cstgResult = await this._apiClient!.callCstgApi(request, opts);
390-
391-
this.setIdentity(cstgResult.identity);
417+
if (cstgResult.status == 'success') {
418+
this.setIdentity(cstgResult.identity);
419+
} else if (cstgResult.status === 'optout') {
420+
this.validateAndSetIdentity(null, IdentityStatus.OPTOUT);
421+
this._callbackManager.runCallbacks(EventType.OptoutReceived, {});
422+
this._callbackManager.runCallbacks(EventType.IdentityUpdated, {});
423+
} else {
424+
const errorText = 'Unexpected status received from CSTG endpoint.';
425+
this._logger.warn(errorText);
426+
throw new Error(errorText);
427+
}
392428
}
393429

394430
protected throwIfInitNotComplete(message: string) {

0 commit comments

Comments
 (0)