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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand All @@ -79,7 +79,7 @@ Per-user tokens for your API tools (used as `{{session.authTokens.<name>}}`) 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-<SDK-TOKEN>` 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-<SDK-TOKEN>` 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 });
Expand Down
8 changes: 6 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,12 @@ export namespace Gleap {
function setUserJwt(token: string): Promise<void>;
/**
* 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<string | null>) | null): void;
/**
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion src/Gleap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>)) | null} provider
*/
static setUserJwtProvider(provider) {
Expand Down
36 changes: 31 additions & 5 deletions src/GleapHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down Expand Up @@ -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) => {
Expand All @@ -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) {}
};

Expand All @@ -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) {}
};

Expand Down
51 changes: 50 additions & 1 deletion src/GleapHelper.test.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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$/);
});
});
71 changes: 70 additions & 1 deletion src/GleapSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -534,6 +538,7 @@ export default class GleapSession {
this.contactVersion++;
this.logoutGeneration++;
this.signedModeIdentifySent = false;
this.signedModeProviderAsked = false;
this.identifyFailedTransiently = false;

GleapFrameManager.getInstance().sendMessage(
Expand Down Expand Up @@ -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();
Expand All @@ -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) : ''}`;

Expand Down Expand Up @@ -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) {}
}
};

/**
Expand Down
Loading
Loading