Skip to content
Merged
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
7 changes: 7 additions & 0 deletions AUTHENTICATED_FILES.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 57 additions & 2 deletions src/GleapSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default class GleapSession {
realtimeHost = undefined;
sdkKey = null;
updatingSession = false;
fileRefreshTimeout = null;
useCookies = true;
localStorageDisabled = false;
session = {
Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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?.(() => {});
}
};

/**
Expand Down Expand Up @@ -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;
}

Expand Down
49 changes: 49 additions & 0 deletions src/GleapSession.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});