Skip to content

Commit 7086553

Browse files
floooatclaude
andcommitted
fix(identify): send a userHash once even when the cached identity is unchanged (#227678)
identifySession skipped any identify whose userId and data matched the cached session, also when it now carries a userHash. A contact identified without a hash before (an older app version, identity verification switched on later, a sign-up identified before it could get a hash) therefore never sent the hash, and the server never marked it as proved (session.signedUserId). In Gleap's own support project that kept long-signed-in dashboard users "unverified" for Kai. The SDK now remembers `<gleapId>:<userId>` once a hashed identify landed (memory + cache, cleared on logout) and sends a hashed identify when that record does not match the current session and user. The hash itself is never stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 6efdcfa commit 7086553

2 files changed

Lines changed: 108 additions & 1 deletion

File tree

‎src/GleapSession.js‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ export default class GleapSession {
5050
// never re-identified from stale state.
5151
lastIdentify = null;
5252
identifyInFlight = false;
53+
// `<gleapId>:<userId>` once an identify with a userHash for that user landed on
54+
// that session (memory, plus the cache unless localStorage is disabled).
55+
// An unchanged identify is skipped, but a session identified without a hash
56+
// before (an older app version, identity verification switched on later, a
57+
// sign-up identified before it could get one) still has to send the hash
58+
// once: only that request lets the server mark the contact as proved.
59+
// Keyed by user, never by the hash itself, so no credential is stored.
60+
signedIdentity = null;
5361
// identify / updateContact requests still waiting for the server. A survey
5462
// started meanwhile would get the previous contact (Surveys 2.0 skips the
5563
// contact questions an identified contact already answered), so showSurvey
@@ -248,6 +256,7 @@ export default class GleapSession {
248256
// replay state so the next session can't inherit a stale identity.
249257
this.lastIdentify = null;
250258
this.identifyInFlight = false;
259+
this.forgetSignedIdentity();
251260
this.contactVersion++;
252261

253262
GleapFrameManager.getInstance().sendMessage(
@@ -643,14 +652,50 @@ export default class GleapSession {
643652
}));
644653
};
645654

655+
signedIdentityKey = () => `signed-identify-${this.sdkKey}`;
656+
657+
signedIdentityOf = (gleapId, userId) => `${gleapId}:${userId}`;
658+
659+
rememberSignedIdentity = (sessionData, userId) => {
660+
if (!sessionData || !sessionData.gleapId || userId === undefined || userId === null) {
661+
return;
662+
}
663+
this.signedIdentity = this.signedIdentityOf(sessionData.gleapId, userId);
664+
if (!this.localStorageDisabled) {
665+
try {
666+
saveToGleapCache(this.signedIdentityKey(), this.signedIdentity);
667+
} catch (exp) {}
668+
}
669+
};
670+
671+
forgetSignedIdentity = () => {
672+
this.signedIdentity = null;
673+
try {
674+
saveToGleapCache(this.signedIdentityKey(), null);
675+
} catch (exp) {}
676+
};
677+
678+
/** A userHash this session has not been identified with yet for this user. */
679+
needsSignedIdentify = (userId, userHash) => {
680+
if (!userHash || userId === undefined || userId === null || !this.session || !this.session.gleapId) {
681+
return false;
682+
}
683+
if (!this.signedIdentity && !this.localStorageDisabled) {
684+
try {
685+
this.signedIdentity = loadFromGleapCache(this.signedIdentityKey());
686+
} catch (exp) {}
687+
}
688+
return this.signedIdentity !== this.signedIdentityOf(this.session.gleapId, userId);
689+
};
690+
646691
identifySession = (userId, userData, userHash, refreshFileSession = false) => {
647692
// Remember the args before any early-out: a no-op identify against a
648693
// session that later gets recreated must still be replayable.
649694
this.lastIdentify = { userId, userData, userHash };
650695

651696
const sessionNeedsUpdate = this.checkIfSessionNeedsUpdate(userId, userData);
652697
const needsFileIdentity = !!userHash && this.session?.authenticatedFilesRequired && (!this.session?.fileAccessToken || Date.parse(this.session.fileAccessExpiresAt) <= Date.now());
653-
if (!sessionNeedsUpdate && !refreshFileSession && !needsFileIdentity) {
698+
if (!sessionNeedsUpdate && !refreshFileSession && !needsFileIdentity && !this.needsSignedIdentify(userId, userHash)) {
654699
return;
655700
}
656701

@@ -684,6 +729,9 @@ export default class GleapSession {
684729
try {
685730
const sessionData = JSON.parse(http.responseText);
686731
self.contactVersion++;
732+
if (userHash) {
733+
self.rememberSignedIdentity(sessionData, userId);
734+
}
687735
self.validateSession(sessionData);
688736
resolve(sessionData);
689737
} catch (exp) {

‎src/GleapSession.test.js‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,65 @@ describe('identify replay', () => {
236236
});
237237
});
238238

239+
describe('identify with a userHash on a session identified without one', () => {
240+
const USER_DATA = { email: 'bec@example.com', name: 'Bec' };
241+
const respondIdentified = (xhr) => xhr.respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION }));
242+
243+
it('sends the hashed identify once, although the cached session already has this user and data', () => {
244+
seedCachedSession();
245+
const session = buildSession();
246+
session.startSession();
247+
248+
session.identifySession('user-1', USER_DATA, 'verified-hmac');
249+
250+
expect(identifyRequests()).toHaveLength(1);
251+
expect(JSON.parse(identifyRequests()[0].body).userHash).toBe('verified-hmac');
252+
respondIdentified(identifyRequests()[0]);
253+
254+
// The next page load: same cached session, same identify, nothing to prove again.
255+
const nextBoot = buildSession();
256+
nextBoot.startSession();
257+
nextBoot.identifySession('user-1', USER_DATA, 'verified-hmac');
258+
expect(identifyRequests()).toHaveLength(1);
259+
});
260+
261+
it('sends it again when the hashed identify failed', () => {
262+
seedCachedSession();
263+
const session = buildSession();
264+
session.startSession();
265+
session.identifySession('user-1', USER_DATA, 'verified-hmac')?.catch?.(() => {});
266+
identifyRequests()[0].respond(503);
267+
268+
const nextBoot = buildSession();
269+
nextBoot.startSession();
270+
nextBoot.identifySession('user-1', USER_DATA, 'verified-hmac');
271+
expect(identifyRequests()).toHaveLength(2);
272+
});
273+
274+
it('still skips an unchanged identify without a hash', () => {
275+
seedCachedSession();
276+
const session = buildSession();
277+
session.startSession();
278+
session.identifySession('user-1', USER_DATA);
279+
expect(identifyRequests()).toHaveLength(0);
280+
});
281+
282+
it('forgets the proof on logout', () => {
283+
seedCachedSession();
284+
const session = buildSession();
285+
session.startSession();
286+
session.identifySession('user-1', USER_DATA, 'verified-hmac');
287+
respondIdentified(identifyRequests()[0]);
288+
289+
session.clearSession(0, false);
290+
seedCachedSession();
291+
const nextBoot = buildSession();
292+
nextBoot.startSession();
293+
nextBoot.identifySession('user-1', USER_DATA, 'verified-hmac');
294+
expect(identifyRequests()).toHaveLength(2);
295+
});
296+
});
297+
239298
describe('user switch in the same browser', () => {
240299
const OTHER_USER_SESSION = {
241300
gleapId: 'gleap-id-b',

0 commit comments

Comments
 (0)