From b17e38b091667453099f08b7ad2a5c409912d1af Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Fri, 10 Jul 2026 11:24:52 +1200 Subject: [PATCH] feat(audience): warn and drop identify()/alias() calls with a malformed passport id Studios sometimes call identify() or alias() with identityType passport but pass their own internal user id instead of a real Passport id, which silently pollutes downstream analytics. Reject the call (no-op, no event sent, userId untouched) and warn unconditionally when the id doesn't look like a Passport id, using the same connection|id or UUID shape Passport actually issues. Also adds sample-app coverage for the new behaviour. --- packages/audience/core/src/index.ts | 4 +- packages/audience/core/src/validation.test.ts | 35 ++++ packages/audience/core/src/validation.ts | 12 ++ packages/audience/sdk-sample-app/index.html | 8 + .../audience/sdk-sample-app/sample-app.js | 16 ++ packages/audience/sdk/src/sdk.test.ts | 171 ++++++++++++++++++ packages/audience/sdk/src/sdk.ts | 41 ++++- 7 files changed, 280 insertions(+), 7 deletions(-) diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index 890e31c159..50a3fed72a 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -38,7 +38,9 @@ export type { TransportResult, AudienceErrorCode } from './errors'; export { TransportError, AudienceError } from './errors'; export { MessageQueue } from './queue'; export { collectContext } from './context'; -export { isTimestampValid, isAliasValid, truncate } from './validation'; +export { + isTimestampValid, isAliasValid, isPassportIdValid, truncate, +} from './validation'; export { getOrCreateSession } from './session'; export type { SessionResult } from './session'; diff --git a/packages/audience/core/src/validation.test.ts b/packages/audience/core/src/validation.test.ts index 9564acdaad..4c486eb77b 100644 --- a/packages/audience/core/src/validation.test.ts +++ b/packages/audience/core/src/validation.test.ts @@ -1,6 +1,7 @@ import { isTimestampValid, isAliasValid, + isPassportIdValid, truncate, truncateSource, } from './validation'; @@ -49,6 +50,40 @@ describe('isAliasValid', () => { }); }); +describe('isPassportIdValid', () => { + it('accepts a connection|id shaped ID', () => { + expect(isPassportIdValid('email|abc123')).toBe(true); + }); + + it('accepts a google-oauth2 connection ID', () => { + expect(isPassportIdValid('google-oauth2|11261203362550278288455')).toBe(true); + }); + + it('accepts a bare UUID', () => { + expect(isPassportIdValid('550e8400-e29b-41d4-a716-446655440000')).toBe(true); + }); + + it('rejects an ID with no pipe and not a UUID', () => { + expect(isPassportIdValid('12345')).toBe(false); + }); + + it('rejects an ID with more than one pipe', () => { + expect(isPassportIdValid('email|abc|123')).toBe(false); + }); + + it('rejects an empty string', () => { + expect(isPassportIdValid('')).toBe(false); + }); + + it('accepts a UUID with surrounding whitespace, e.g. from a trailing newline in a text field', () => { + expect(isPassportIdValid(' 550e8400-e29b-41d4-a716-446655440000\n')).toBe(true); + }); + + it('does not let surrounding whitespace alone turn an invalid ID into a valid one', () => { + expect(isPassportIdValid(' 12345 ')).toBe(false); + }); +}); + describe('truncate', () => { it('returns the original string when within the limit', () => { expect(truncate('hello', 256)).toBe('hello'); diff --git a/packages/audience/core/src/validation.ts b/packages/audience/core/src/validation.ts index 3f85d67e99..073de343ac 100644 --- a/packages/audience/core/src/validation.ts +++ b/packages/audience/core/src/validation.ts @@ -16,6 +16,18 @@ export function isTimestampValid(eventTimestamp: string): boolean { return ts <= now + MAX_FUTURE_MS && ts >= now - MAX_PAST_MS; } +const PASSPORT_ID_RE = /^[^|]+\|[^|]+$/; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Validate that an ID looks like a Passport ID: either `|` + * (e.g. `email|abc123`, `google-oauth2|456`) or a bare UUID. + */ +export function isPassportIdValid(id: string): boolean { + const trimmed = id.trim(); + return PASSPORT_ID_RE.test(trimmed) || UUID_RE.test(trimmed); +} + /** * Validate that alias from and to are not the same identity. */ diff --git a/packages/audience/sdk-sample-app/index.html b/packages/audience/sdk-sample-app/index.html index 3f338273d8..9cf7f29903 100644 --- a/packages/audience/sdk-sample-app/index.html +++ b/packages/audience/sdk-sample-app/index.html @@ -208,7 +208,15 @@

Identify user identify(id, type, traits) + +

+ Fills in an ID that isn't Passport-shaped with identity type Passport, so you can + click "Identify user" and see the SDK warn and drop the call. +

diff --git a/packages/audience/sdk-sample-app/sample-app.js b/packages/audience/sdk-sample-app/sample-app.js index 7e41498323..e60d955a5a 100644 --- a/packages/audience/sdk-sample-app/sample-app.js +++ b/packages/audience/sdk-sample-app/sample-app.js @@ -258,6 +258,10 @@ var currentSessionId = null; var pendingQueueCount = 0; + // Set by the console mirror when the SDK rejects a passport id, so + // onIdentify() can tell a dropped call from one that went through. + var lastIdentifyRejected = false; + function installConsoleMirror() { var originalLog = console.log.bind(console); var originalWarn = console.warn.bind(console); @@ -283,6 +287,7 @@ var msg = args[0].slice(11); var payload = args.length > 1 ? args[1] : undefined; harvestIds(payload); + if (msg.indexOf("doesn't look like a Passport ID") !== -1) lastIdentifyRejected = true; // Promote flush outcomes to ok/err so they stand out in the log. var flushMatch = msg.match(/^flush (ok|failed)/); if (flushMatch && flushMatch[1] === 'ok') { pendingQueueCount = 0; updateStatus(); } @@ -349,6 +354,7 @@ 'btn-consent-none', 'btn-consent-anon', 'btn-consent-full', 'btn-custom-event', 'btn-identify', + 'btn-identify-invalid-example', 'btn-delete-data', ]; @@ -563,6 +569,11 @@ $('btn-consent-anon').addEventListener('click', function () { onSetConsent('anonymous'); }); $('btn-consent-full').addEventListener('click', function () { onSetConsent('full'); }); $('btn-identify').addEventListener('click', onIdentify); + $('btn-identify-invalid-example').addEventListener('click', function () { + $('id-id').value = '12345'; + $('id-type').value = IdentityType.Passport; + saveUiState(); + }); $('btn-alias').addEventListener('click', onAlias); $('btn-delete-data').addEventListener('click', onDeleteData); ['alias-from-id', 'alias-to-id', 'alias-from-type', 'alias-to-type'].forEach(function (id) { @@ -812,7 +823,12 @@ try { traits = parseTraits($('id-traits').value); } catch (err) { log('identify()', 'invalid traits JSON: ' + err.message, 'err'); return; } try { + lastIdentifyRejected = false; audience.identify(id, type, traits); + if (lastIdentifyRejected) { + log('identify()', 'dropped — invalid Passport ID (see warning above)', 'err'); + return; + } enqueued(); currentUserId = id; identityMirror.userId = id; diff --git a/packages/audience/sdk/src/sdk.test.ts b/packages/audience/sdk/src/sdk.test.ts index b2304da73d..d714818d75 100644 --- a/packages/audience/sdk/src/sdk.test.ts +++ b/packages/audience/sdk/src/sdk.test.ts @@ -942,6 +942,105 @@ describe('Audience', () => { expect(ids).toHaveLength(0); sdk.shutdown(); }); + + it('warns and drops the call when identityType is passport but the id is not passport shaped', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = createSDK({ consent: 'full' }); + + sdk.identify('12345', 'passport'); + await sdk.flush(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('doesn\'t look like a')); + const ids = sentMessages().filter((m: any) => m.type === 'identify'); + expect(ids).toHaveLength(0); + + warnSpy.mockRestore(); + sdk.shutdown(); + }); + + it('warns without needing debug mode enabled', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = createSDK({ consent: 'full', debug: false }); + + sdk.identify('12345', 'passport'); + + expect(warnSpy).toHaveBeenCalled(); + + warnSpy.mockRestore(); + sdk.shutdown(); + }); + + it('does not persist userId after a dropped passport identify, so later track calls stay anonymous', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = createSDK({ consent: 'full' }); + + sdk.identify('12345', 'passport'); + sdk.track('sign_up', { method: 'email' }); + await sdk.flush(); + + const trackMsg = sentMessages().find((m: any) => m.eventName === 'sign_up'); + expect(trackMsg.userId).toBeUndefined(); + + warnSpy.mockRestore(); + sdk.shutdown(); + }); + + it('does not warn for a passport shaped id', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = createSDK({ consent: 'full' }); + + sdk.identify('email|abc123', 'passport'); + await sdk.flush(); + + expect(warnSpy).not.toHaveBeenCalled(); + const msg = sentMessages().find((m: any) => m.type === 'identify'); + expect(msg).toBeDefined(); + + warnSpy.mockRestore(); + sdk.shutdown(); + }); + + it('does not warn for a bare UUID passport id', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = createSDK({ consent: 'full' }); + + sdk.identify('550e8400-e29b-41d4-a716-446655440000', 'passport'); + await sdk.flush(); + + expect(warnSpy).not.toHaveBeenCalled(); + const msg = sentMessages().find((m: any) => m.type === 'identify'); + expect(msg).toBeDefined(); + + warnSpy.mockRestore(); + sdk.shutdown(); + }); + + it('stores and sends the trimmed id, not the padded one, for a valid passport id', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(' email|abc123\n', 'passport'); + await sdk.flush(); + + const msg = sentMessages().find((m: any) => m.type === 'identify'); + expect(msg.userId).toBe('email|abc123'); + + sdk.shutdown(); + }); + + it('does not warn for non-passport identity types regardless of id shape', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = createSDK({ consent: 'full' }); + + sdk.identify('12345', 'steam'); + await sdk.flush(); + + expect(warnSpy).not.toHaveBeenCalled(); + const msg = sentMessages().find((m: any) => m.type === 'identify'); + expect(msg).toBeDefined(); + + warnSpy.mockRestore(); + sdk.shutdown(); + }); }); describe('alias', () => { @@ -978,6 +1077,78 @@ describe('Audience', () => { sdk.shutdown(); }); + it('warns and drops the call when to.identityType is passport but to.id is not passport shaped', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = createSDK({ consent: 'full' }); + + sdk.alias(TEST_STEAM, { id: '12345', identityType: 'passport' }); + await sdk.flush(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('doesn\'t look like a')); + const aliases = sentMessages().filter((m: any) => m.type === 'alias'); + expect(aliases).toHaveLength(0); + + warnSpy.mockRestore(); + sdk.shutdown(); + }); + + it('warns and drops the call when from.identityType is passport but from.id is not passport shaped', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = createSDK({ consent: 'full' }); + + sdk.alias({ id: '12345', identityType: 'passport' }, TEST_STEAM); + await sdk.flush(); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('doesn\'t look like a')); + const aliases = sentMessages().filter((m: any) => m.type === 'alias'); + expect(aliases).toHaveLength(0); + + warnSpy.mockRestore(); + sdk.shutdown(); + }); + + it('does not warn when the passport side has a passport-shaped id', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = createSDK({ consent: 'full' }); + + sdk.alias(TEST_STEAM, { id: 'email|abc123', identityType: 'passport' }); + await sdk.flush(); + + expect(warnSpy).not.toHaveBeenCalled(); + const msg = sentMessages().find((m: any) => m.type === 'alias'); + expect(msg).toBeDefined(); + + warnSpy.mockRestore(); + sdk.shutdown(); + }); + + it('stores and sends the trimmed id, not the padded one, for a valid passport id', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.alias(TEST_STEAM, { id: ' email|abc123\n', identityType: 'passport' }); + await sdk.flush(); + + const msg = sentMessages().find((m: any) => m.type === 'alias'); + expect(msg.toId).toBe('email|abc123'); + + sdk.shutdown(); + }); + + it('does not warn when neither side is passport, regardless of id shape', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sdk = createSDK({ consent: 'full' }); + + sdk.alias({ id: '12345', identityType: 'steam' }, { id: '67890', identityType: 'epic' }); + await sdk.flush(); + + expect(warnSpy).not.toHaveBeenCalled(); + const msg = sentMessages().find((m: any) => m.type === 'alias'); + expect(msg).toBeDefined(); + + warnSpy.mockRestore(); + sdk.shutdown(); + }); + it('is a no-op at none consent', async () => { const sdk = createSDK({ consent: 'none' }); diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts index 650b4bc8aa..b32116b591 100644 --- a/packages/audience/sdk/src/sdk.ts +++ b/packages/audience/sdk/src/sdk.ts @@ -2,7 +2,6 @@ import type { Attribution, ConsentLevel, ConsentManager, - IdentityType, Message, UserTraits, } from '@imtbl/audience-core'; @@ -19,8 +18,10 @@ import { deleteCookie, generateId, getTimestamp, + IdentityType, isAliasValid, isTimestampValid, + isPassportIdValid, truncate, collectContext, collectSessionAttribution, @@ -48,6 +49,18 @@ const UTM_EVENTS: ReadonlySet = new Set([ 'sign_up', 'link_clicked', ]); +// Bypasses the debug gate: a caller bug, not routine noise. The sample app +// string-matches "doesn't look like a Passport ID" to detect a drop — keep +// that phrase if this wording changes. +function warnMalformedPassportId(method: string, typeLabel: string, idLabel: string, id: string): void { + // eslint-disable-next-line no-console + console.warn( + `${LOG_PREFIX} ${method} called with ${typeLabel} 'passport' but ${idLabel} "${id}" doesn't look ` + + 'like a Passport ID (expected a format like "email|123" or a UUID). Check you\'re passing ' + + 'the Passport user ID, not your own internal user ID. Call ignored.', + ); +} + /** * Track player activity on your website — page views, purchases, sign-ups — * and tie it to player identity when they log in. @@ -342,16 +355,22 @@ export class Audience { * * `sdk.identify('user@example.com', 'email', { name: 'Jane' })` * - * Requires 'full' consent. + * Requires 'full' consent. When `identityType` is `'passport'`, the id must + * look like a real Passport id (`connection|id` or a UUID) — otherwise the + * call is dropped and a warning is logged. */ identify(id: string, identityType: IdentityType, traits?: UserTraits): void { if (!canIdentify(this.consent.level)) { this.debug.logWarning('identify() requires full consent — call ignored.'); return; } + if (identityType === IdentityType.Passport && !isPassportIdValid(id)) { + warnMalformedPassportId('identify()', 'identityType', 'id', id); + return; + } this.refreshSession(); - const resolvedId = truncate(id); + const resolvedId = truncate(id.trim()); this.userId = resolvedId; this.enqueue('identify', { ...this.baseMessage(), @@ -368,7 +387,9 @@ export class Audience { * different account (e.g. Passport email). This tells the backend they're * the same person so analytics aren't split across two profiles. * - * Requires 'full' consent. `from` and `to` must differ. + * Requires 'full' consent. `from` and `to` must differ. When either side's + * `identityType` is `'passport'`, that side's id must look like a real + * Passport id (`connection|id` or a UUID) — otherwise the call is dropped. */ alias( from: { id: string; identityType: IdentityType }, @@ -382,14 +403,22 @@ export class Audience { this.debug.logWarning('alias() from and to are identical — call ignored.'); return; } + if (from.identityType === IdentityType.Passport && !isPassportIdValid(from.id)) { + warnMalformedPassportId('alias()', 'from.identityType', 'from.id', from.id); + return; + } + if (to.identityType === IdentityType.Passport && !isPassportIdValid(to.id)) { + warnMalformedPassportId('alias()', 'to.identityType', 'to.id', to.id); + return; + } this.refreshSession(); this.enqueue('alias', { ...this.baseMessage(), type: 'alias', - fromId: truncate(from.id), + fromId: truncate(from.id.trim()), fromType: from.identityType, - toId: truncate(to.id), + toId: truncate(to.id.trim()), toType: to.identityType, }); }