From 7086553d465e2d7bfe61399fe9d5fae1fece2032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Florian=20B=C3=B6hler?= Date: Wed, 4 Nov 2026 10:47:54 +0100 Subject: [PATCH] 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 `:` 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 --- src/GleapSession.js | 50 +++++++++++++++++++++++++++++++++- src/GleapSession.test.js | 59 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/GleapSession.js b/src/GleapSession.js index 97f5f12c..352c5ddc 100644 --- a/src/GleapSession.js +++ b/src/GleapSession.js @@ -50,6 +50,14 @@ export default class GleapSession { // never re-identified from stale state. lastIdentify = null; identifyInFlight = false; + // `:` once an identify with a userHash for that user landed on + // that session (memory, plus the cache unless localStorage is disabled). + // An unchanged identify is skipped, but a session identified without a hash + // before (an older app version, identity verification switched on later, a + // sign-up identified before it could get one) still has to send the hash + // once: only that request lets the server mark the contact as proved. + // Keyed by user, never by the hash itself, so no credential is stored. + signedIdentity = null; // identify / updateContact requests still waiting for the server. A survey // started meanwhile would get the previous contact (Surveys 2.0 skips the // contact questions an identified contact already answered), so showSurvey @@ -248,6 +256,7 @@ export default class GleapSession { // replay state so the next session can't inherit a stale identity. this.lastIdentify = null; this.identifyInFlight = false; + this.forgetSignedIdentity(); this.contactVersion++; GleapFrameManager.getInstance().sendMessage( @@ -643,6 +652,42 @@ export default class GleapSession { })); }; + signedIdentityKey = () => `signed-identify-${this.sdkKey}`; + + signedIdentityOf = (gleapId, userId) => `${gleapId}:${userId}`; + + rememberSignedIdentity = (sessionData, userId) => { + if (!sessionData || !sessionData.gleapId || userId === undefined || userId === null) { + return; + } + this.signedIdentity = this.signedIdentityOf(sessionData.gleapId, userId); + if (!this.localStorageDisabled) { + try { + saveToGleapCache(this.signedIdentityKey(), this.signedIdentity); + } catch (exp) {} + } + }; + + forgetSignedIdentity = () => { + this.signedIdentity = null; + try { + saveToGleapCache(this.signedIdentityKey(), null); + } catch (exp) {} + }; + + /** A userHash this session has not been identified with yet for this user. */ + needsSignedIdentify = (userId, userHash) => { + if (!userHash || userId === undefined || userId === null || !this.session || !this.session.gleapId) { + return false; + } + if (!this.signedIdentity && !this.localStorageDisabled) { + try { + this.signedIdentity = loadFromGleapCache(this.signedIdentityKey()); + } catch (exp) {} + } + return this.signedIdentity !== this.signedIdentityOf(this.session.gleapId, userId); + }; + identifySession = (userId, userData, userHash, refreshFileSession = false) => { // Remember the args before any early-out: a no-op identify against a // session that later gets recreated must still be replayable. @@ -650,7 +695,7 @@ export default class GleapSession { const sessionNeedsUpdate = this.checkIfSessionNeedsUpdate(userId, userData); const needsFileIdentity = !!userHash && this.session?.authenticatedFilesRequired && (!this.session?.fileAccessToken || Date.parse(this.session.fileAccessExpiresAt) <= Date.now()); - if (!sessionNeedsUpdate && !refreshFileSession && !needsFileIdentity) { + if (!sessionNeedsUpdate && !refreshFileSession && !needsFileIdentity && !this.needsSignedIdentify(userId, userHash)) { return; } @@ -684,6 +729,9 @@ export default class GleapSession { try { const sessionData = JSON.parse(http.responseText); self.contactVersion++; + if (userHash) { + self.rememberSignedIdentity(sessionData, userId); + } self.validateSession(sessionData); resolve(sessionData); } catch (exp) { diff --git a/src/GleapSession.test.js b/src/GleapSession.test.js index 227ebb39..853be34b 100644 --- a/src/GleapSession.test.js +++ b/src/GleapSession.test.js @@ -236,6 +236,65 @@ describe('identify replay', () => { }); }); +describe('identify with a userHash on a session identified without one', () => { + const USER_DATA = { email: 'bec@example.com', name: 'Bec' }; + const respondIdentified = (xhr) => xhr.respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION })); + + it('sends the hashed identify once, although the cached session already has this user and data', () => { + seedCachedSession(); + const session = buildSession(); + session.startSession(); + + session.identifySession('user-1', USER_DATA, 'verified-hmac'); + + expect(identifyRequests()).toHaveLength(1); + expect(JSON.parse(identifyRequests()[0].body).userHash).toBe('verified-hmac'); + respondIdentified(identifyRequests()[0]); + + // The next page load: same cached session, same identify, nothing to prove again. + const nextBoot = buildSession(); + nextBoot.startSession(); + nextBoot.identifySession('user-1', USER_DATA, 'verified-hmac'); + expect(identifyRequests()).toHaveLength(1); + }); + + it('sends it again when the hashed identify failed', () => { + seedCachedSession(); + const session = buildSession(); + session.startSession(); + session.identifySession('user-1', USER_DATA, 'verified-hmac')?.catch?.(() => {}); + identifyRequests()[0].respond(503); + + const nextBoot = buildSession(); + nextBoot.startSession(); + nextBoot.identifySession('user-1', USER_DATA, 'verified-hmac'); + expect(identifyRequests()).toHaveLength(2); + }); + + it('still skips an unchanged identify without a hash', () => { + seedCachedSession(); + const session = buildSession(); + session.startSession(); + session.identifySession('user-1', USER_DATA); + expect(identifyRequests()).toHaveLength(0); + }); + + it('forgets the proof on logout', () => { + seedCachedSession(); + const session = buildSession(); + session.startSession(); + session.identifySession('user-1', USER_DATA, 'verified-hmac'); + respondIdentified(identifyRequests()[0]); + + session.clearSession(0, false); + seedCachedSession(); + const nextBoot = buildSession(); + nextBoot.startSession(); + nextBoot.identifySession('user-1', USER_DATA, 'verified-hmac'); + expect(identifyRequests()).toHaveLength(2); + }); +}); + describe('user switch in the same browser', () => { const OTHER_USER_SESSION = { gleapId: 'gleap-id-b',