From c1d8755a3f3ba56184f8c0a701be16a8a6779e37 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Mon, 21 Sep 2026 12:20:49 -0400 Subject: [PATCH] feat: add opt-in authenticated conversation files --- AUTHENTICATED_FILES.md | 7 +++++ src/GleapSession.js | 59 ++++++++++++++++++++++++++++++++++++++-- src/GleapSession.test.js | 49 +++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 AUTHENTICATED_FILES.md diff --git a/AUTHENTICATED_FILES.md b/AUTHENTICATED_FILES.md new file mode 100644 index 00000000..d2a81495 --- /dev/null +++ b/AUTHENTICATED_FILES.md @@ -0,0 +1,7 @@ +# Authenticated conversation files + +Companion to Server `AUTHENTICATED_FILES.md`. When the API flags a project as requiring authenticated files, a successful server-verified `identify` call supplies a short-lived file session to Messenger. The SDK refreshes it while active, preserves it only for the same identity, and requests revocation on logout. Unverified anonymous sessions cannot read protected files. + +A customer application configured for emailed file links must preserve `gleapFile` through its own login flow and call verified `identify`; the SDK then asks the API for the authorized conversation and opens it. Deploy the API and compatible Messenger before enabling the per-project setting. Offline revocation cannot reach the server; tokens still expire after 15 minutes. + +Validation: `npm test -- --runInBand --watchman=false` (277 tests), `npm run build`. No package publishing or synchronized SDK version bump is included. diff --git a/src/GleapSession.js b/src/GleapSession.js index 9636d91b..290bcc21 100644 --- a/src/GleapSession.js +++ b/src/GleapSession.js @@ -29,6 +29,7 @@ export default class GleapSession { realtimeHost = undefined; sdkKey = null; updatingSession = false; + fileRefreshTimeout = null; useCookies = true; localStorageDisabled = false; session = { @@ -155,6 +156,16 @@ export default class GleapSession { }; clearSession = (attemp = 0, retry = true) => { + clearTimeout(this.fileRefreshTimeout); + this.openingProtectedFile = null; + if (this.session?.fileAccessToken) { + try { + const revoke = new XMLHttpRequest(); + revoke.open('POST', this.apiUrl + '/files/session/revoke'); + revoke.setRequestHeader('X-File-Session', this.session.fileAccessToken); + revoke.send(); + } catch (_) { /* Local logout still clears credentials if offline. */ } + } if (this.session && this.session.gleapHash) { GleapEventManager.notifyEvent('unregister-pushmessage-group', `gleapuser-${this.session.gleapHash}`); } @@ -212,11 +223,40 @@ export default class GleapSession { }, newTimeout * 1000); }; + openRequestedProtectedFile = () => { + const fileId = new URLSearchParams(window.location?.search || '').get('gleapFile'); + if (!/^[a-f0-9]{24}$/.test(fileId || '') || this.openingProtectedFile === fileId) return; + this.openingProtectedFile = fileId; + const token = this.session.fileAccessToken; + const request = new XMLHttpRequest(); + request.open('GET', `${this.apiUrl}/files/${fileId}/location`); + request.setRequestHeader('X-File-Session', this.session.fileAccessToken); + request.onload = () => { + if (this.session?.fileAccessToken !== token) return; + if (request.status !== 200) { this.openingProtectedFile = null; return; } + try { + const { shareToken } = JSON.parse(request.responseText); + const frame = GleapFrameManager.getInstance(); + frame.setAppMode('widget'); + frame.sendMessage({ name: 'open-conversation', data: { shareToken } }, true); + frame.showWidget(); + } catch { this.openingProtectedFile = null; } + }; + request.onerror = () => { this.openingProtectedFile = null; }; + request.send(); + }; + validateSession = (session) => { if (!session || !session.gleapId) { return; } + // Ordinary session refreshes must not discard the independently verified, + // short-lived file session. Never carry it across an identity change. + if (!session.fileAccessToken && session.gleapId === this.session?.gleapId && + session.userId === this.session?.userId && Date.parse(this.session.fileAccessExpiresAt) > Date.now()) { + session = { ...session, fileAccessToken: this.session.fileAccessToken, fileAccessExpiresAt: this.session.fileAccessExpiresAt }; + } let sessionChanged = false; if (this.session?.gleapId !== session?.gleapId) { sessionChanged = true; @@ -236,6 +276,16 @@ export default class GleapSession { this.session = session; this.ready = true; + if (session.fileAccessToken) this.openRequestedProtectedFile(); + clearTimeout(this.fileRefreshTimeout); + if (session.fileAccessToken && this.lastIdentify?.userHash) { + this.fileRefreshTimeout = setTimeout(() => { + if (this.lastIdentify) { + const { userId, userData, userHash } = this.lastIdentify; + this.identifySession(userId, userData, userHash, true)?.catch?.(() => {}); + } + }, Math.max(0, Date.parse(session.fileAccessExpiresAt) - Date.now() - 5 * 60 * 1000)); + } // Register new push group. if (this.session && this.session.gleapHash) { @@ -272,6 +322,10 @@ export default class GleapSession { if (sessionChanged) { this.replayIdentifyIfNeeded(); } + if (session.authenticatedFilesRequired && !session.fileAccessToken && this.lastIdentify?.userHash && !this.identifyInFlight) { + const { userId, userData, userHash } = this.lastIdentify; + this.identifySession(userId, userData, userHash, true)?.catch?.(() => {}); + } }; /** @@ -475,13 +529,14 @@ export default class GleapSession { }); }; - identifySession = (userId, userData, userHash) => { + 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. this.lastIdentify = { userId, userData, userHash }; const sessionNeedsUpdate = this.checkIfSessionNeedsUpdate(userId, userData); - if (!sessionNeedsUpdate) { + const needsFileIdentity = !!userHash && this.session?.authenticatedFilesRequired && (!this.session?.fileAccessToken || Date.parse(this.session.fileAccessExpiresAt) <= Date.now()); + if (!sessionNeedsUpdate && !refreshFileSession && !needsFileIdentity) { return; } diff --git a/src/GleapSession.test.js b/src/GleapSession.test.js index 846ba5d7..3782443d 100644 --- a/src/GleapSession.test.js +++ b/src/GleapSession.test.js @@ -235,3 +235,52 @@ describe('identify replay', () => { expect(identifyRequests().length).toBe(0); }); }); + +describe('authenticated file sessions', () => { + it('keeps partial session updates independent of file identification', () => { + const session = buildSession(); + session.validateSession({ ...CACHED_IDENTIFIED_SESSION }); + expect(() => session.updateSession({ email: CACHED_IDENTIFIED_SESSION.email })).not.toThrow(); + }); + it('does not postpone file refresh when ordinary session polling repeats', () => { + const session = buildSession(); + session.lastIdentify = { userId: 'user-1', userData: {}, userHash: 'verified-hmac' }; + session.validateSession({ ...CACHED_IDENTIFIED_SESSION, fileAccessToken: 'file-session', fileAccessExpiresAt: new Date(Date.now() + 900000).toISOString() }); + jest.advanceTimersByTime(300000); + session.validateSession({ ...CACHED_IDENTIFIED_SESSION }); + jest.advanceTimersByTime(300000); + expect(identifyRequests()).toHaveLength(1); + }); + it('re-verifies an unchanged cached identity when the project now requires authenticated files', () => { + const session = buildSession(); + session.validateSession({ ...CACHED_IDENTIFIED_SESSION, authenticatedFilesRequired: true }); + session.identifySession('user-1', { email: 'bec@example.com', name: 'Bec' }, 'verified-hmac'); + expect(identifyRequests()).toHaveLength(1); + expect(JSON.parse(identifyRequests()[0].body).userHash).toBe('verified-hmac'); + }); + it('preserves a still-valid file session on ordinary refresh but never across identity changes', () => { + const session = buildSession(); + session.validateSession({ ...CACHED_IDENTIFIED_SESSION, fileAccessToken: 'file-session', fileAccessExpiresAt: new Date(Date.now() + 900000).toISOString() }); + session.validateSession({ ...CACHED_IDENTIFIED_SESSION }); + expect(session.session.fileAccessToken).toBe('file-session'); + session.validateSession({ ...CACHED_IDENTIFIED_SESSION, gleapId: 'different', userId: 'different-user' }); + expect(session.session.fileAccessToken).toBeUndefined(); + }); + it('refreshes file authentication with identity proof before its 15 minute expiry', () => { + const session = buildSession(); + session.lastIdentify = { userId: 'user-1', userData: { email: 'bec@example.com' }, userHash: 'verified-hmac' }; + session.validateSession({ ...CACHED_IDENTIFIED_SESSION, fileAccessToken: 'file-session', fileAccessExpiresAt: new Date(Date.now() + 900000).toISOString() }); + jest.advanceTimersByTime(600000); + expect(identifyRequests()).toHaveLength(1); + expect(JSON.parse(identifyRequests()[0].body).userHash).toBe('verified-hmac'); + }); + it('revokes the file session on logout and cancels credential refresh', () => { + const session = buildSession(); + session.validateSession({ ...CACHED_IDENTIFIED_SESSION, fileAccessToken: 'file-session', fileAccessExpiresAt: new Date(Date.now() + 900000).toISOString() }); + session.clearSession(0, false); + const revoke = MockXhr.instances.find((xhr) => xhr.url.endsWith('/files/session/revoke')); + expect(revoke.headers['X-File-Session']).toBe('file-session'); + expect(session.session.fileAccessToken).toBeUndefined(); + expect(session.lastIdentify).toBeNull(); + }); +});