Skip to content
Open
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
4 changes: 3 additions & 1 deletion packages/audience/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
35 changes: 35 additions & 0 deletions packages/audience/core/src/validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
isTimestampValid,
isAliasValid,
isPassportIdValid,
truncate,
truncateSource,
} from './validation';
Expand Down Expand Up @@ -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');
Expand Down
12 changes: 12 additions & 0 deletions packages/audience/core/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<connection>|<id>`
* (e.g. `email|abc123`, `google-oauth2|456`) or a bare UUID.
*/
export function isPassportIdValid(id: string): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Something we should keep in mind, when we migrate from Auth0 -> Ory Kratos, the user id format will change. Will become UUIDs I think.

Hmm actually, probably need to consult with @brayansdt the implications of some of these things when we migrate.

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.
*/
Expand Down
8 changes: 8 additions & 0 deletions packages/audience/sdk-sample-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,15 @@ <h1>
Identify user
<small class="btn-sig">identify(id, type, traits)</small>
</button>
<button id="btn-identify-invalid-example" disabled>
Fill invalid Passport ID example
<small class="btn-sig">id "12345", type passport</small>
</button>
</div>
<p class="helper-text">
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.
</p>
</div>
</details>

Expand Down
16 changes: 16 additions & 0 deletions packages/audience/sdk-sample-app/sample-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(); }
Expand Down Expand Up @@ -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',
];

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
171 changes: 171 additions & 0 deletions packages/audience/sdk/src/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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' });

Expand Down
Loading