Skip to content

Commit f324169

Browse files
authored
Merge pull request #147 from GleapSDK/feat/authenticated-conversation-files
feat: maintain verified sessions for protected conversation files
2 parents a2a85fe + c1d8755 commit f324169

3 files changed

Lines changed: 113 additions & 2 deletions

File tree

AUTHENTICATED_FILES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Authenticated conversation files
2+
3+
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.
4+
5+
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.
6+
7+
Validation: `npm test -- --runInBand --watchman=false` (277 tests), `npm run build`. No package publishing or synchronized SDK version bump is included.

src/GleapSession.js

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export default class GleapSession {
2929
realtimeHost = undefined;
3030
sdkKey = null;
3131
updatingSession = false;
32+
fileRefreshTimeout = null;
3233
useCookies = true;
3334
localStorageDisabled = false;
3435
session = {
@@ -155,6 +156,16 @@ export default class GleapSession {
155156
};
156157

157158
clearSession = (attemp = 0, retry = true) => {
159+
clearTimeout(this.fileRefreshTimeout);
160+
this.openingProtectedFile = null;
161+
if (this.session?.fileAccessToken) {
162+
try {
163+
const revoke = new XMLHttpRequest();
164+
revoke.open('POST', this.apiUrl + '/files/session/revoke');
165+
revoke.setRequestHeader('X-File-Session', this.session.fileAccessToken);
166+
revoke.send();
167+
} catch (_) { /* Local logout still clears credentials if offline. */ }
168+
}
158169
if (this.session && this.session.gleapHash) {
159170
GleapEventManager.notifyEvent('unregister-pushmessage-group', `gleapuser-${this.session.gleapHash}`);
160171
}
@@ -212,11 +223,40 @@ export default class GleapSession {
212223
}, newTimeout * 1000);
213224
};
214225

226+
openRequestedProtectedFile = () => {
227+
const fileId = new URLSearchParams(window.location?.search || '').get('gleapFile');
228+
if (!/^[a-f0-9]{24}$/.test(fileId || '') || this.openingProtectedFile === fileId) return;
229+
this.openingProtectedFile = fileId;
230+
const token = this.session.fileAccessToken;
231+
const request = new XMLHttpRequest();
232+
request.open('GET', `${this.apiUrl}/files/${fileId}/location`);
233+
request.setRequestHeader('X-File-Session', this.session.fileAccessToken);
234+
request.onload = () => {
235+
if (this.session?.fileAccessToken !== token) return;
236+
if (request.status !== 200) { this.openingProtectedFile = null; return; }
237+
try {
238+
const { shareToken } = JSON.parse(request.responseText);
239+
const frame = GleapFrameManager.getInstance();
240+
frame.setAppMode('widget');
241+
frame.sendMessage({ name: 'open-conversation', data: { shareToken } }, true);
242+
frame.showWidget();
243+
} catch { this.openingProtectedFile = null; }
244+
};
245+
request.onerror = () => { this.openingProtectedFile = null; };
246+
request.send();
247+
};
248+
215249
validateSession = (session) => {
216250
if (!session || !session.gleapId) {
217251
return;
218252
}
219253

254+
// Ordinary session refreshes must not discard the independently verified,
255+
// short-lived file session. Never carry it across an identity change.
256+
if (!session.fileAccessToken && session.gleapId === this.session?.gleapId &&
257+
session.userId === this.session?.userId && Date.parse(this.session.fileAccessExpiresAt) > Date.now()) {
258+
session = { ...session, fileAccessToken: this.session.fileAccessToken, fileAccessExpiresAt: this.session.fileAccessExpiresAt };
259+
}
220260
let sessionChanged = false;
221261
if (this.session?.gleapId !== session?.gleapId) {
222262
sessionChanged = true;
@@ -236,6 +276,16 @@ export default class GleapSession {
236276

237277
this.session = session;
238278
this.ready = true;
279+
if (session.fileAccessToken) this.openRequestedProtectedFile();
280+
clearTimeout(this.fileRefreshTimeout);
281+
if (session.fileAccessToken && this.lastIdentify?.userHash) {
282+
this.fileRefreshTimeout = setTimeout(() => {
283+
if (this.lastIdentify) {
284+
const { userId, userData, userHash } = this.lastIdentify;
285+
this.identifySession(userId, userData, userHash, true)?.catch?.(() => {});
286+
}
287+
}, Math.max(0, Date.parse(session.fileAccessExpiresAt) - Date.now() - 5 * 60 * 1000));
288+
}
239289

240290
// Register new push group.
241291
if (this.session && this.session.gleapHash) {
@@ -272,6 +322,10 @@ export default class GleapSession {
272322
if (sessionChanged) {
273323
this.replayIdentifyIfNeeded();
274324
}
325+
if (session.authenticatedFilesRequired && !session.fileAccessToken && this.lastIdentify?.userHash && !this.identifyInFlight) {
326+
const { userId, userData, userHash } = this.lastIdentify;
327+
this.identifySession(userId, userData, userHash, true)?.catch?.(() => {});
328+
}
275329
};
276330

277331
/**
@@ -475,13 +529,14 @@ export default class GleapSession {
475529
});
476530
};
477531

478-
identifySession = (userId, userData, userHash) => {
532+
identifySession = (userId, userData, userHash, refreshFileSession = false) => {
479533
// Remember the args before any early-out: a no-op identify against a
480534
// session that later gets recreated must still be replayable.
481535
this.lastIdentify = { userId, userData, userHash };
482536

483537
const sessionNeedsUpdate = this.checkIfSessionNeedsUpdate(userId, userData);
484-
if (!sessionNeedsUpdate) {
538+
const needsFileIdentity = !!userHash && this.session?.authenticatedFilesRequired && (!this.session?.fileAccessToken || Date.parse(this.session.fileAccessExpiresAt) <= Date.now());
539+
if (!sessionNeedsUpdate && !refreshFileSession && !needsFileIdentity) {
485540
return;
486541
}
487542

src/GleapSession.test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,52 @@ describe('identify replay', () => {
235235
expect(identifyRequests().length).toBe(0);
236236
});
237237
});
238+
239+
describe('authenticated file sessions', () => {
240+
it('keeps partial session updates independent of file identification', () => {
241+
const session = buildSession();
242+
session.validateSession({ ...CACHED_IDENTIFIED_SESSION });
243+
expect(() => session.updateSession({ email: CACHED_IDENTIFIED_SESSION.email })).not.toThrow();
244+
});
245+
it('does not postpone file refresh when ordinary session polling repeats', () => {
246+
const session = buildSession();
247+
session.lastIdentify = { userId: 'user-1', userData: {}, userHash: 'verified-hmac' };
248+
session.validateSession({ ...CACHED_IDENTIFIED_SESSION, fileAccessToken: 'file-session', fileAccessExpiresAt: new Date(Date.now() + 900000).toISOString() });
249+
jest.advanceTimersByTime(300000);
250+
session.validateSession({ ...CACHED_IDENTIFIED_SESSION });
251+
jest.advanceTimersByTime(300000);
252+
expect(identifyRequests()).toHaveLength(1);
253+
});
254+
it('re-verifies an unchanged cached identity when the project now requires authenticated files', () => {
255+
const session = buildSession();
256+
session.validateSession({ ...CACHED_IDENTIFIED_SESSION, authenticatedFilesRequired: true });
257+
session.identifySession('user-1', { email: 'bec@example.com', name: 'Bec' }, 'verified-hmac');
258+
expect(identifyRequests()).toHaveLength(1);
259+
expect(JSON.parse(identifyRequests()[0].body).userHash).toBe('verified-hmac');
260+
});
261+
it('preserves a still-valid file session on ordinary refresh but never across identity changes', () => {
262+
const session = buildSession();
263+
session.validateSession({ ...CACHED_IDENTIFIED_SESSION, fileAccessToken: 'file-session', fileAccessExpiresAt: new Date(Date.now() + 900000).toISOString() });
264+
session.validateSession({ ...CACHED_IDENTIFIED_SESSION });
265+
expect(session.session.fileAccessToken).toBe('file-session');
266+
session.validateSession({ ...CACHED_IDENTIFIED_SESSION, gleapId: 'different', userId: 'different-user' });
267+
expect(session.session.fileAccessToken).toBeUndefined();
268+
});
269+
it('refreshes file authentication with identity proof before its 15 minute expiry', () => {
270+
const session = buildSession();
271+
session.lastIdentify = { userId: 'user-1', userData: { email: 'bec@example.com' }, userHash: 'verified-hmac' };
272+
session.validateSession({ ...CACHED_IDENTIFIED_SESSION, fileAccessToken: 'file-session', fileAccessExpiresAt: new Date(Date.now() + 900000).toISOString() });
273+
jest.advanceTimersByTime(600000);
274+
expect(identifyRequests()).toHaveLength(1);
275+
expect(JSON.parse(identifyRequests()[0].body).userHash).toBe('verified-hmac');
276+
});
277+
it('revokes the file session on logout and cancels credential refresh', () => {
278+
const session = buildSession();
279+
session.validateSession({ ...CACHED_IDENTIFIED_SESSION, fileAccessToken: 'file-session', fileAccessExpiresAt: new Date(Date.now() + 900000).toISOString() });
280+
session.clearSession(0, false);
281+
const revoke = MockXhr.instances.find((xhr) => xhr.url.endsWith('/files/session/revoke'));
282+
expect(revoke.headers['X-File-Session']).toBe('file-session');
283+
expect(session.session.fileAccessToken).toBeUndefined();
284+
expect(session.lastIdentify).toBeNull();
285+
});
286+
});

0 commit comments

Comments
 (0)