diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ef6f44..06e6b3b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 19.5.2 +Fixed: `Gleap.setUserJwtProvider` alone never signed the session again after a page reload. When the grant cookie was missing (an IP-address host, or the cookie deleted) and the app set only the provider, without calling `setUserJwt`, the user stayed unsigned although the server reported Signed mode. The SDK now asks the provider once per page load when the server reports Signed mode for an identified session without a valid grant and no token is held, and identifies with the returned token like `setUserJwt` (a token for another user goes through the usual `identity_mismatch` recovery). A provider that returns `null` sends nothing. Also applies when the provider is set after the session loaded. +Fixed: cookies were never stored on IP-address hosts (for example `http://127.0.0.1:8080` or `http://[::1]:8080`). The SDK wrote an invalid `domain` attribute there, so the browser dropped the session and grant cookies. On an IP address they are now host-only cookies (no `domain`), and erasing them works the same way. Domain hosts and `localhost` are unchanged. + ## 19.5.1 Fixed: the `getIdentity()` typing lists the identified user's fields again (`userId`, `name`, `email`, `phone`, `companyId`) next to `gleapId`, `gleapHash` and `grant`; 19.5.0 typed only the session pair and the grant, which broke TypeScript callers that read the user from it. No runtime change. diff --git a/README.md b/README.md index 348859da..72256491 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ const token = jwt.sign( Gleap.setUserJwt(token); ``` -The token is kept in memory only. To renew it when it expires, give the SDK a function that fetches a fresh one from your backend: +The token is kept in memory only. To renew it when it expires, and to sign the session again after a page reload when the grant cookie is missing, give the SDK a function that fetches a fresh one from your backend: ``` Gleap.setUserJwtProvider(() => fetch('/api/gleap-token').then((res) => res.text())); @@ -79,7 +79,7 @@ Per-user tokens for your API tools (used as `{{session.authTokens.}}`) are Gleap.setAuthTokens({ backend: userAccessToken }); ``` -A valid token signs the session: the server answers with a grant that the SDK sends as the `Gleap-Grant` header on every request and hands to the messenger. It is kept in memory and in the `gleap-grant-` cookie on your top-level domain (unless `Gleap.setUseCookies(false)`), so a page reload and a help center on a subdomain stay signed until it expires. The project's session max age (Settings > Security, 7 days by default) sets its lifetime; shorten it per app with `sessionDuration` in milliseconds: +A valid token signs the session: the server answers with a grant that the SDK sends as the `Gleap-Grant` header on every request and hands to the messenger. It is kept in memory and in the `gleap-grant-` cookie on your top-level domain (unless `Gleap.setUseCookies(false)`; on an IP address such as `127.0.0.1` it is a host-only cookie), so a page reload and a help center on a subdomain stay signed until it expires. The project's session max age (Settings > Security, 7 days by default) sets its lifetime; shorten it per app with `sessionDuration` in milliseconds: ``` Gleap.initialize("SDK-TOKEN", { sessionDuration: 24 * 60 * 60 * 1000 }); diff --git a/index.d.ts b/index.d.ts index cd46880a..682ba24d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -190,8 +190,12 @@ export namespace Gleap { function setUserJwt(token: string): Promise; /** * Returns a fresh identity token. Called when the current token expired, or before - * a renewal when it expires within 5 minutes. One call at a time, 10 s timeout, - * 30 s pause after a failure. `null` removes the provider. + * a renewal when it expires within 5 minutes. Also restores the signed session after + * a page reload: when the server reports Signed mode for an identified session without + * a valid grant (no grant cookie) and no token was passed to `setUserJwt`, it is asked + * once per page load and the user is identified with its token. Returning `null` + * sends nothing. One call at a time, 10 s timeout, 30 s pause after a failure. + * `null` removes the provider. */ function setUserJwtProvider(provider: (() => string | null | Promise) | null): void; /** diff --git a/package-lock.json b/package-lock.json index b36f48e5..1827dd99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gleap", - "version": "19.5.1", + "version": "19.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gleap", - "version": "19.5.1", + "version": "19.5.2", "license": "Commercial", "dependencies": { "@floating-ui/dom": "^1.7.5", diff --git a/package.json b/package.json index 8b82c972..96dcf966 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "description": "JavaScript and TypeScript SDK for Gleap, an Intercom alternative with AI customer support, live chat, in-app bug reporting and customer feedback.", "name": "gleap", - "version": "19.5.1", + "version": "19.5.2", "main": "build/cjs/index.js", "module": "build/esm/index.mjs", "exports": { diff --git a/src/Gleap.js b/src/Gleap.js index 42df5c08..5c785543 100755 --- a/src/Gleap.js +++ b/src/Gleap.js @@ -507,7 +507,9 @@ class Gleap { /** * Sets a function that returns a fresh identity token (or a Promise of one). - * Gleap calls it when the current token expired or is about to. `null` removes it. + * Gleap calls it when the current token expired or is about to, and once per page + * load to sign the session again when the server reports Signed mode without a valid + * grant and no token was passed to setUserJwt. `null` removes it. * @param {(() => (string | null | Promise)) | null} provider */ static setUserJwtProvider(provider) { diff --git a/src/GleapHelper.js b/src/GleapHelper.js index 48f9afa6..39dcb49c 100644 --- a/src/GleapHelper.js +++ b/src/GleapHelper.js @@ -629,6 +629,34 @@ const removeSubDomain = (v) => { return v; }; +const IPV4_ADDRESS = /^\d{1,3}(\.\d{1,3}){3}$/; + +/** + * The cookie Domain attribute for a page on `host` (location.host, port + * included): the top-level domain, or null for an IP address (IPv4, or IPv6 + * in brackets or bare), where a Domain attribute makes the browser drop the + * cookie, so the cookie stays host-only. `localhost` keeps `domain=localhost`. + */ +export const cookieDomainFor = (host) => { + if (!host || typeof host !== 'string') { + return null; + } + // [::1]:8080, or a bare IPv6 address (more than one colon). + if (host.charAt(0) === '[' || host.indexOf(':') !== host.lastIndexOf(':')) { + return null; + } + const hostname = host.split(':')[0]; + if (!hostname || IPV4_ADDRESS.test(hostname)) { + return null; + } + return removeSubDomain(hostname); +}; + +const cookieDomainAttribute = (attributeName) => { + const domain = cookieDomainFor(window.location.host); + return domain ? '; ' + attributeName + '=' + domain : ''; +}; + export const loadFromGleapCache = (key) => { try { const cachedData = localStorage.getItem(`gleap-widget-${key}`); @@ -659,7 +687,7 @@ export const clearGleapCache = (key) => { }; /** - * Writes a cookie on the top-level domain. `credential`: the value is a + * Writes a cookie on the top-level domain (host-only on an IP address). `credential`: the value is a * credential, so it gets SameSite=Lax and, on https, Secure. */ export const setGleapCookie = (name, value, days, credential = false) => { @@ -670,12 +698,11 @@ export const setGleapCookie = (name, value, days, credential = false) => { date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000); expires = '; expires=' + date.toUTCString(); } - const host = removeSubDomain(window.location.host.split(':')[0]); var attributes = ''; if (credential) { attributes = '; SameSite=Lax' + (window.location.protocol === 'https:' ? '; Secure' : ''); } - document.cookie = name + '=' + (value || '') + expires + '; path=/; domain=' + host + attributes; + document.cookie = name + '=' + (value || '') + expires + '; path=/' + cookieDomainAttribute('domain') + attributes; } catch (exp) {} }; @@ -694,8 +721,7 @@ export const getGleapCookie = (name) => { export const eraseGleapCookie = (name) => { try { - const host = removeSubDomain(window.location.host.split(':')[0]); - document.cookie = name + '=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; Domain=' + host; + document.cookie = name + '=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT' + cookieDomainAttribute('Domain'); } catch (exp) {} }; diff --git a/src/GleapHelper.test.js b/src/GleapHelper.test.js index 9377f40f..e7d78c4c 100644 --- a/src/GleapHelper.test.js +++ b/src/GleapHelper.test.js @@ -1,4 +1,4 @@ -import { flattenCompany, formatRelativeTime, setGleapCookie } from './GleapHelper'; +import { cookieDomainFor, eraseGleapCookie, flattenCompany, formatRelativeTime, setGleapCookie } from './GleapHelper'; describe('formatRelativeTime — the notification meta line', () => { const agoBy = (ms) => new Date(Date.now() - ms).toISOString(); @@ -312,3 +312,52 @@ describe('setGleapCookie', () => { expect(cookie).not.toContain('Secure'); }); }); + +describe('cookie domain', () => { + const onHost = (host, write) => { + const previous = { window: global.window, document: global.document }; + global.window = { location: { host, protocol: 'https:' } }; + global.document = { cookie: '' }; + write(); + const cookie = global.document.cookie; + global.window = previous.window; + global.document = previous.document; + return cookie; + }; + + test.each([ + ['app.example.com', 'example.com'], + ['app.example.com:8443', 'example.com'], + ['www.shop.example.co.uk', 'example.co.uk'], + ['localhost', 'localhost'], + ['localhost:3000', 'localhost'], + ['127.0.0.1', null], + ['127.0.0.1:8080', null], + ['192.168.1.20', null], + ['[::1]', null], + ['[::1]:8080', null], + ['[2001:db8::7]:443', null], + ['::1', null], + ['', null], + ])('cookieDomainFor(%p) is %p', (host, domain) => { + expect(cookieDomainFor(host)).toBe(domain); + }); + + test.each(['127.0.0.1:8080', '[::1]:8080'])('on %s the cookie is host-only and erasing it works the same way', (host) => { + const written = onHost(host, () => setGleapCookie('gleap-grant-key', 'grant', 1, true)); + expect(written).toMatch(/^gleap-grant-key=grant; expires=[^;]+; path=\/; SameSite=Lax; Secure$/); + expect(written.toLowerCase()).not.toContain('domain'); + + const erased = onHost(host, () => eraseGleapCookie('gleap-grant-key')); + expect(erased).toBe('gleap-grant-key=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT'); + }); + + test('a domain host and localhost keep their Domain attribute', () => { + expect(onHost('app.example.com', () => setGleapCookie('session-key', 'v', 365))).toContain( + '; path=/; domain=example.com' + ); + expect(onHost('app.example.com', () => eraseGleapCookie('session-key'))).toMatch(/; Domain=example\.com$/); + expect(onHost('localhost:3000', () => setGleapCookie('session-key', 'v', 365))).toContain('; path=/; domain=localhost'); + expect(onHost('localhost:3000', () => eraseGleapCookie('session-key'))).toMatch(/; Domain=localhost$/); + }); +}); diff --git a/src/GleapSession.js b/src/GleapSession.js index c801ff68..a36bb4d0 100644 --- a/src/GleapSession.js +++ b/src/GleapSession.js @@ -125,6 +125,10 @@ export default class GleapSession { // project switched to Signed after they were identified). signedModeIdentifySent = false; lastSignedModeIdentifyAt = 0; + // The same answer when no token is at hand (setUserJwt's token lives in + // memory only, so a reload has none) but a JWT provider is set: the + // provider is asked once per session load for a token to identify with. + signedModeProviderAsked = false; // An identify that failed on the network or with a server error: replayed // once the browser comes back online. identifyFailedTransiently = false; @@ -534,6 +538,7 @@ export default class GleapSession { this.contactVersion++; this.logoutGeneration++; this.signedModeIdentifySent = false; + this.signedModeProviderAsked = false; this.identifyFailedTransiently = false; GleapFrameManager.getInstance().sendMessage( @@ -784,9 +789,13 @@ export default class GleapSession { this.lastSignedModeIdentifyAt = Date.now(); }; - /** A /sessions answer in signed mode without a grant: sends the held identify once for one. */ + /** + * A /sessions answer in signed mode without a grant: sends the held identify + * once for one. Without a held token, asks the JWT provider for one instead. + */ identifyForSignedModeIfNeeded = () => { if (!this.needsSignedModeIdentify()) { + this.identifyWithProviderForSignedModeIfNeeded(); return; } this.markSignedModeIdentify(); @@ -799,6 +808,60 @@ export default class GleapSession { } catch (exp) {} }; + /** + * Signed mode on an identified session without a live grant, with no token + * at hand (a reload after setUserJwt, or an identify without one) but a JWT + * provider set, and the provider not asked for it this session load yet. + */ + needsSignedModeProviderIdentify = () => { + if (this.signedModeProviderAsked || this.signedModeIdentifySent || this.identifyInFlight || !this.jwtProvider) { + return false; + } + const identity = this.session && this.session.identity; + if (!this.session || !this.session.userId || !identity || identity.mode !== 'signed') { + return false; + } + if ((this.lastIdentify && this.lastIdentify.userHash) || this.hasLiveGrant()) { + return false; + } + return Date.now() - this.lastSignedModeIdentifyAt >= GRANT_REFRESH_MIN_INTERVAL_MS; + }; + + /** + * Restores a signed session from the JWT provider alone: asks it once per + * session load and identifies with the token like setUserJwt (a token for + * another user than the session's goes through identity_mismatch). No + * token: nothing happens. Returns a Promise that never rejects (undefined + * when nothing was asked). + */ + identifyWithProviderForSignedModeIfNeeded = () => { + if (!this.needsSignedModeProviderIdentify()) { + return; + } + this.signedModeProviderAsked = true; + const generationAtAsk = this.logoutGeneration; + return this.requestUserJwt() + .then((token) => { + if (!token || this.logoutGeneration !== generationAtAsk || this.identifyInFlight) { + return; + } + // The app passed a token meanwhile, or the session no longer needs one. + const identity = this.session && this.session.identity; + if ( + (this.lastIdentify && this.lastIdentify.userHash) || + !this.session || + !this.session.userId || + !identity || + identity.mode !== 'signed' || + this.hasLiveGrant() + ) { + return; + } + return this.setUserJwt(token); + }) + .catch(() => {}); + }; + /** Memory-only fingerprint of a refused identify (never the token itself). */ refusedIdentityOf = (userId, userHash) => `${userId}:${userHash ? fingerprintOf(userHash) : ''}`; @@ -834,6 +897,12 @@ export default class GleapSession { } this.jwtProvider = provider; this.jwtProviderFailedAt = 0; + // Set after the /sessions answer arrived: a signed session can still be restored. + if (this.ready) { + try { + this.identifyWithProviderForSignedModeIfNeeded(); + } catch (exp) {} + } }; /** diff --git a/src/GleapSession.test.js b/src/GleapSession.test.js index b3d9907f..370dc522 100644 --- a/src/GleapSession.test.js +++ b/src/GleapSession.test.js @@ -972,6 +972,184 @@ describe('grant (signed session credential)', () => { }); }); + describe('signed mode restored by the JWT provider alone (reload without setUserJwt)', () => { + const SIGNED_MODE = { identity: { signed: false, grant: null, mode: 'signed' } }; + const sessionsRequests = () => MockXhr.instances.filter((xhr) => xhr.url.endsWith('/sessions')); + const answerSignedMode = (xhr, extra = {}) => xhr.respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, ...SIGNED_MODE, ...extra })); + + // A reload: the identified session is cached, the grant cookie is gone (IP host, cookie deleted) + // and setUserJwt's token lived in memory only. The app sets only the provider. + const reloadWithProvider = (provider) => { + seedCachedSession(); + const session = buildSession(); + if (provider) { + session.setUserJwtProvider(provider); + } + session.startSession(); + return session; + }; + + it('asks the provider once and identifies with its token, which brings the grant back', async () => { + const token = jwtOf({ userId: 'user-1', exp: inSeconds(3600) }); + const provider = jest.fn(() => Promise.resolve(token)); + const session = reloadWithProvider(provider); + expect(provider).not.toHaveBeenCalled(); + + answerSignedMode(sessionsRequests()[0]); + await flush(); + expect(provider).toHaveBeenCalledTimes(1); + expect(identifyRequests()).toHaveLength(1); + const body = JSON.parse(identifyRequests()[0].body); + expect(body.userId).toBe('user-1'); + expect(body.userHash).toBe(token); + expect(session.lastIdentify.userHash).toBe(token); + + const grant = grantFor('gleap-id-1'); + respondWithGrant(identifyRequests()[0], grant); + expect(session.grant).toBe(grant); + expect(setGleapCookie).toHaveBeenCalledWith('gleap-grant-sdk-key', encodeURIComponent(grant), expect.any(Number), true); + }); + + it('no loop: at most one provider call and one identify per session load', async () => { + const provider = jest.fn(() => Promise.resolve(jwtOf({ userId: 'user-1', exp: inSeconds(3600) }))); + const session = reloadWithProvider(provider); + answerSignedMode(sessionsRequests()[0]); + await flush(); + expect(identifyRequests()).toHaveLength(1); + + // The identify answers without a grant: still unsigned, but nothing is asked or sent again. + answerSignedMode(identifyRequests()[0]); + await flush(); + session.startSession(); + answerSignedMode(sessionsRequests()[1]); + await flush(); + jest.advanceTimersByTime(10 * 60 * 1000); + session.startSession(); + answerSignedMode(sessionsRequests()[2]); + await flush(); + expect(provider).toHaveBeenCalledTimes(1); + expect(identifyRequests()).toHaveLength(1); + }); + + it('one call at a time: a second signed-mode answer while the provider runs asks nothing more', async () => { + let resolveToken; + const provider = jest.fn(() => new Promise((resolve) => (resolveToken = resolve))); + const session = reloadWithProvider(provider); + answerSignedMode(sessionsRequests()[0]); + session.startSession(); + answerSignedMode(sessionsRequests()[1]); + await flush(); + expect(provider).toHaveBeenCalledTimes(1); + + resolveToken(jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); + await flush(); + expect(identifyRequests()).toHaveLength(1); + }); + + it('no provider: no identify', async () => { + reloadWithProvider(null); + answerSignedMode(sessionsRequests()[0]); + await flush(); + expect(identifyRequests()).toHaveLength(0); + }); + + it('the provider returns null: nothing is sent, nothing logged, and it is not asked again this load', async () => { + const provider = jest.fn(() => null); + const session = reloadWithProvider(provider); + answerSignedMode(sessionsRequests()[0]); + await flush(); + expect(provider).toHaveBeenCalledTimes(1); + expect(identifyRequests()).toHaveLength(0); + expect(warn).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(10 * 60 * 1000); + session.startSession(); + answerSignedMode(sessionsRequests()[1]); + await flush(); + expect(provider).toHaveBeenCalledTimes(1); + expect(identifyRequests()).toHaveLength(0); + }); + + it('a token for another user goes through identity_mismatch (fresh visitor, identify once more)', async () => { + const token = jwtOf({ userId: 'user-2', exp: inSeconds(3600) }); + const provider = jest.fn(() => token); + const session = reloadWithProvider(provider); + answerSignedMode(sessionsRequests()[0]); + await flush(); + expect(identifyRequests()).toHaveLength(1); + expect(JSON.parse(identifyRequests()[0].body).userId).toBe('user-2'); + + identifyRequests()[0].respond(400, JSON.stringify({ error: { details: { value: 'identity_mismatch' } } })); + await flush(); + expect(session.session.gleapId).toBe(null); + const fresh = sessionsRequests()[sessionsRequests().length - 1]; + expect(fresh.headers['Gleap-Id']).toBeUndefined(); + fresh.respond(200, JSON.stringify({ gleapId: 'gleap-id-new', gleapHash: 'gleap-hash-new' })); + await flush(); + + expect(identifyRequests()).toHaveLength(2); + expect(identifyRequests()[1].headers['Gleap-Id']).toBe('gleap-id-new'); + expect(JSON.parse(identifyRequests()[1].body).userHash).toBe(token); + expect(provider).toHaveBeenCalledTimes(1); + }); + + it('a provider set after the /sessions answer is still asked once', async () => { + const provider = jest.fn(() => jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); + const session = reloadWithProvider(null); + answerSignedMode(sessionsRequests()[0]); + await flush(); + expect(identifyRequests()).toHaveLength(0); + + session.setUserJwtProvider(provider); + session.setUserJwtProvider(provider); + await flush(); + expect(provider).toHaveBeenCalledTimes(1); + expect(identifyRequests()).toHaveLength(1); + }); + + it('does not ask for a Classic answer, an anonymous session, a live grant or a held token', async () => { + const provider = jest.fn(() => jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); + + reloadWithProvider(provider); + sessionsRequests()[0].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION })); + + const anonymous = buildSession(); + anonymous.setUserJwtProvider(provider); + anonymous.startSession(); + sessionsRequests()[1].respond(200, JSON.stringify({ gleapId: 'gleap-id-9', gleapHash: 'gleap-hash-9', ...SIGNED_MODE })); + + getGleapCookie.mockImplementation((name) => (name === 'gleap-grant-sdk-key' ? encodeURIComponent(grantFor('gleap-id-1')) : null)); + reloadWithProvider(provider); + answerSignedMode(sessionsRequests()[2], { identity: { signed: true, mode: 'signed' } }); + getGleapCookie.mockReset().mockImplementation(() => null); + await flush(); + expect(provider).not.toHaveBeenCalled(); + expect(identifyRequests()).toHaveLength(0); + + // The app passes its token itself: the held identify is sent, the provider is not asked. + const held = reloadWithProvider(provider); + held.setUserJwt(jwtOf({ userId: 'user-1', plan: 'pro', exp: inSeconds(3600) })); + expect(identifyRequests()).toHaveLength(1); + respondWithGrant(identifyRequests()[0], null); + answerSignedMode(sessionsRequests()[3]); + await flush(); + expect(provider).not.toHaveBeenCalled(); + }); + + it('a logout while the provider runs drops its token', async () => { + let resolveToken; + const provider = jest.fn(() => new Promise((resolve) => (resolveToken = resolve))); + const session = reloadWithProvider(provider); + answerSignedMode(sessionsRequests()[0]); + await flush(); + session.clearSession(0, false); + resolveToken(jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); + await flush(); + expect(identifyRequests()).toHaveLength(0); + expect(session.lastIdentify).toBeNull(); + }); + }); + it('drops an identify answer that lands after a logout', async () => { const session = readySession(); session.setUserJwt(jwtOf({ userId: 'user-1', exp: inSeconds(3600) }));