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
6 changes: 4 additions & 2 deletions AUTHENTICATED_FILES.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# 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.
Companion to Server `AUTHENTICATED_FILES.md`. When the API flags a project as requiring authenticated files, a successful server-verified identify (a signed identity token via `Gleap.setUserJwt`, or the legacy user hash via `Gleap.identify(userId, data, userHash)`) 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. Guests and contacts identified without a signed identity (token or legacy hash) 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.
The grant a signed identify returns (`identity.grant`) is a separate credential: it rides as `Gleap-Grant` on every SDK request (file uploads and feedback included) and goes to Messenger next to the session, never inside it, so the file session and the grant are never persisted together. Logout drops both.

A customer application configured for emailed file links must preserve `gleapFile` through its own login flow and identify the customer with a signed identity (`Gleap.setUserJwt`, or the legacy user hash); 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.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 19.5.0
Added: signed customer identity. `Gleap.setUserJwt(token)` identifies the user with an identity token (HS256 JWT signed by your backend); its `userId` claim names the user and every other claim lands on the contact as signed data. The token is kept in memory only, and the call never rejects: refusals are logged.
Added: `Gleap.setUserJwtProvider(provider)` hands Gleap a function that returns a fresh identity token; it is called when the current token expired or is about to.
Added: `Gleap.setAuthTokens(tokens)` stores per-user tokens for your API tools on the signed contact (`{{session.authTokens.<name>}}`); `null` removes one. Kept in memory only.
Added: `Gleap.setSessionDuration(ms)` and `Gleap.initialize(sdkKey, { sessionDuration })` ask the server for a shorter signed session (never beyond the project's maximum). Only sent with an identify when you set it. A boolean second argument to `initialize` (the legacy `disablePing`) is still accepted and typed.
Added: a signed identify returns a grant, the server-signed session credential. The SDK sends it as the `Gleap-Grant` header on every request, keeps it in the `gleap-grant-<apiKey>` cookie (when cookies are on) so a reload stays signed, refreshes it a minute before it runs out, hands it to the messenger, the AI chat bar and agent conversations, and returns it from `Gleap.getIdentity()`. `Gleap.clearIdentity()` drops it everywhere.
Added: when the project runs in signed identity mode (`identity.mode === 'signed'`) and a returning user's session has no live grant, the SDK sends the held identify once to get one, without looping.
Added: identify refusals are recognised by code (`token_expired`, `token_invalid`, `signature_required`, `identity_mismatch`); an expired token asks the JWT provider for a new one.

## 19.4.1
Fixed: since 19.4.0 the SDK failed to load on pages that are not UTF-8 (no charset declared, or for example ISO-8859-1), with `Invalid regular expression: Range out of order in character class`, so the widget did not appear at all. The bundle held raw non-ASCII characters, which such a page decodes with its own encoding. The build now escapes all non-ASCII characters, which also keeps text in the bundle intact on those pages.

Expand Down
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,56 @@ Gleap.setRegion("us");
Gleap.initialize("SDK-TOKEN");
```

### Identify your users

#### Signed identity (recommended)

Your backend signs an HS256 JWT with an identity key from Settings > Security > Identity keys (put the key id in the `kid` header; the secret stays on your server). `userId` is required. Optional claims: `email`, `name`, `company` (`{ id, name }`), `plan`, and provider ids such as `stripeCustomerId`. Set `exp` to at most one hour. The token is the whole identify: every claim lands on the contact as signed data that the browser cannot change. Call `Gleap.setUserJwt(token)` after login and on every page load of a logged-in user. See [Customer identity](https://docs.gleap.ai/documentation/guides/customer-identity).

```
// Your backend (Node, jsonwebtoken). Use an identity key from Settings > Security > Identity keys.
const token = jwt.sign(
{ userId, email, stripeCustomerId, exp: Math.floor(Date.now() / 1000) + 60 * 60 },
SECRET,
{ algorithm: 'HS256', keyid: KEY_ID }
);
```

```
// Your app
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:

```
Gleap.setUserJwtProvider(() => fetch('/api/gleap-token').then((res) => res.text()));
```

Per-user tokens for your API tools (used as `{{session.authTokens.<name>}}`) are stored on the signed contact; `null` removes one:

```
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:

```
Gleap.initialize("SDK-TOKEN", { sessionDuration: 24 * 60 * 60 * 1000 });
```

or later with `Gleap.setSessionDuration(24 * 60 * 60 * 1000)` (the same call as in the mobile SDKs).

`Gleap.setUserJwt` never rejects: a refused token (`token_expired`, `token_invalid`, `signature_required`) or a network error is logged to the console.

`Gleap.getIdentity()` returns `{ gleapId, gleapHash, grant, ... }` for handing the signed session to your own help center or portal. A token for another user than the current session's makes the SDK start a fresh session and identify again (the server merges it into the right contact).

`Gleap.clearIdentity()` on logout clears the contact, the token, the grant (and its cookie) and the auth tokens. The JWT provider stays set.

#### Legacy user hash

Instead of a token you can still call `Gleap.identify(userId, data, userHash)`, with `userHash` the HMAC-SHA256 of the user id created on your server with the project's identity verification secret. It is accepted while "Accept legacy user hash" is on for the project.

### Dark mode (optional)

The widget can match your app's dark/light mode. Enable "Adapt to dark / light mode" in the dashboard (Widget → Look & feel) and set the dark colors there: header colors, UI color and background. In dark mode the widget uses these dark colors, plus the dark logo, header image and composer glow set in the dashboard; without dark colors it keeps its normal colors. Override the scheme at runtime, e.g. from your own theme toggle (only takes effect while "Adapt to dark / light mode" is enabled in the dashboard; until you call it, the dashboard setting applies):
Expand Down
46 changes: 44 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@
export type GleapRegion = "eu" | "us";

export namespace Gleap {
function initialize(sdkKey: string, disablePing?: boolean): void;
/**
* `options.sessionDuration`: how long (ms) a signed session stays signed before a new
* identify is needed. The server never goes beyond the project's session max age.
* A boolean second argument is the legacy `disablePing` flag and is still accepted
* (the ping it controlled no longer exists, so it has no effect).
*/
function initialize(
sdkKey: string,
options?: boolean | { disablePing?: boolean; sessionDuration?: number }
): void;
function sendSilentCrashReport(
description: string,
priority?: "LOW" | "MEDIUM" | "HIGH",
Expand Down Expand Up @@ -168,7 +177,35 @@ export namespace Gleap {
}[]): void;
function showTabNotificationBadge(showNotificationBadge: boolean): void;
function attachNetworkLogs(networkLogs: string): void;
/** Clears the current contact, its identity token and auth tokens. The JWT provider stays set. */
function clearIdentity(): void;
/**
* Identifies the user with an identity token: an HS256 JWT signed by your backend.
* The `userId` (or `user_id`) claim names the user; every other claim lands on the
* contact as signed data. Nothing else is sent. Kept in memory only.
* A token that is not a JWT, has no userId or is over 4096 characters is refused
* with a console warning and no request.
* Never rejects; refusals are logged.
*/
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.
*/
function setUserJwtProvider(provider: (() => string | null | Promise<string | null>) | null): void;
/**
* How long (ms) a signed session stays signed; the server never goes beyond the
* project's maximum. Same as `initialize(sdkKey, { sessionDuration })`. `null` drops it.
*/
function setSessionDuration(sessionDuration: number | null): void;
/**
* Stores per-user tokens for your API tools on the signed contact
* (`{{session.authTokens.<name>}}`). Names: a letter followed by up to 39 letters,
* digits, `_` or `-`. A `null` value removes a token. Kept in memory only and
* sent again after the next signed identify on a new session. Needs `setUserJwt`.
*/
function setAuthTokens(tokens: Record<string, string | null>): Promise<void>;
function setTags(tags: string[]): void;
/**
* Hide notification cards, including cached or already-visible cards, without
Expand Down Expand Up @@ -198,6 +235,7 @@ export namespace Gleap {
avatar?: string | null;
createdAt?: Date | null;
},
/** The HMAC-SHA256 of the user id (legacy), or an identity token (see `setUserJwt`). */
userHash?: string
): void;
function updateContact(
Expand Down Expand Up @@ -451,7 +489,11 @@ export namespace Gleap {
*/
function createAgentChat(options: AgentChatOptions): AgentChatInstance;

function getIdentity(): any;
/**
* The current session, including `gleapId`, `gleapHash` and `grant` (the signed-session
* credential a signed identify returns; null when the session is not signed).
*/
function getIdentity(): { gleapId: string | null; gleapHash: string | null; grant: string | null; [key: string]: any };
function isUserIdentified(): boolean;
function setReplayOptions(options: {
blockClass?: string | RegExp;
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.4.1",
"version": "19.5.0",
"main": "build/cjs/index.js",
"module": "build/esm/index.mjs",
"exports": {
Expand Down
1 change: 1 addition & 0 deletions src/AgentNetworkManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class AgentNetworkManager {
if (s?.sdkKey) headers['Api-Token'] = s.sdkKey;
if (s?.session?.gleapId) headers['Gleap-Id'] = s.session.gleapId;
if (s?.session?.gleapHash) headers['Gleap-Hash'] = s.session.gleapHash;
s?.addGrantHeader?.(headers);
return headers;
}

Expand Down
61 changes: 56 additions & 5 deletions src/Gleap.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,14 +237,22 @@ class Gleap {
/**
* Initializes the SDK
* @param {*} sdkKey
* @param {boolean | { disablePing?: boolean, sessionDuration?: number }} [options]
* sessionDuration: how long (ms) a signed session stays signed; the server never goes
* beyond the project's maximum. A boolean (the legacy `disablePing` argument) and
* `disablePing` are accepted and have no effect: the ping no longer exists.
*/
static initialize(sdkKey) {
static initialize(sdkKey, options) {
const instance = this.getInstance();
if (instance.initialized) {
console.warn('Gleap already initialized.');
return;
}

if (options && typeof options === 'object' && 'sessionDuration' in options) {
GleapSession.getInstance().setSessionDuration(options.sessionDuration);
}

instance.initialized = true;

// Start capturing the session replay as early as possible. Capture is
Expand Down Expand Up @@ -459,7 +467,7 @@ class Gleap {
* @param {*} useCookies
*/
static setUseCookies(useCookies) {
GleapSession.getInstance().useCookies = useCookies;
GleapSession.getInstance().setUseCookies(useCookies);
}

static disableLocalStorage() {
Expand All @@ -475,6 +483,47 @@ class Gleap {
return GleapSession.getInstance().identifySession(userId, flattenCompany(gleapDataParser(userData)), userHash);
}

/**
* Identifies the user with an identity token (HS256 JWT signed by your backend).
* The token is the whole identify: its `userId` (or `user_id`) claim names the user,
* every other claim lands on the contact as signed data. Kept in memory only.
* Never rejects; refusals are logged.
* @param {string} token
* @returns {Promise<void>}
*/
static setUserJwt(token) {
return GleapSession.getInstance().setUserJwt(token);
}

/**
* How long (ms) a signed session stays signed; the server never goes beyond
* the project's maximum. Sent with every identify. `null` drops it.
* Same as `initialize(sdkKey, { sessionDuration })`.
* @param {number | null} sessionDuration
*/
static setSessionDuration(sessionDuration) {
GleapSession.getInstance().setSessionDuration(sessionDuration);
}

/**
* 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.
* @param {(() => (string | null | Promise<string | null>)) | null} provider
*/
static setUserJwtProvider(provider) {
GleapSession.getInstance().setUserJwtProvider(provider);
}

/**
* Stores per-user tokens for your API tools on the signed contact
* (`{{session.authTokens.<name>}}`). A `null` value removes a token. Kept in memory only.
* @param {Object<string, string | null>} tokens
* @returns {Promise<void>}
*/
static setAuthTokens(tokens) {
return GleapSession.getInstance().setAuthTokens(tokens);
}

/**
* Updates the contact data.
* @param {*} userData
Expand All @@ -484,17 +533,19 @@ class Gleap {
}

/**
* Clears the current contact.
* Clears the current contact, its identity token and auth tokens. The JWT provider stays set.
*/
static clearIdentity() {
GleapSession.getInstance().clearSession();
}

/**
* Returns the current user session
* Returns the current user session, plus `grant`: the signed-session credential
* (null when the session is not signed). Hand `{ gleapId, gleapHash, grant }` to a
* help center or portal to keep the user signed in there.
*/
static getIdentity() {
return GleapSession.getInstance().getSession();
return { ...GleapSession.getInstance().getSession(), grant: GleapSession.getInstance().grant || null };
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/GleapAgentConversation.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,17 @@ export const registerGleapAgentComponents = () => {
let sdkKey = '';
let gleapId = '';
let gleapHash = '';
// The signed-session grant, from the same accessor the messenger handover
// uses: a cleared identity clears it here too.
let grant = null;

try {
const session = GleapSession.getInstance();
apiUrl = session.apiUrl || apiUrl;
sdkKey = session.sdkKey || '';
gleapId = session.session?.gleapId || '';
gleapHash = session.session?.gleapHash || '';
grant = session.grant || null;
} catch (e) {}

const config = {
Expand All @@ -136,6 +140,7 @@ export const registerGleapAgentComponents = () => {
sdkKey,
gleapId,
gleapHash,
grant,
};

this._config = config;
Expand Down
10 changes: 10 additions & 0 deletions src/GleapAiChatbarManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,20 @@ export default class GleapAiChatbarManager {
queued.forEach((m) => this._postMessageRaw(m));
}

/** Re-sends the session (and the grant) once the frame is talking to us; before that the ping sends it. */
sendSessionUpdateIfReady() {
if (this.comReady) {
this._sendSessionUpdate();
}
}

_sendSessionUpdate() {
this._postMessageRaw({
name: 'session-update',
data: {
sessionData: GleapSession.getInstance().getSession(),
// Top level, never inside sessionData (the messenger persists that).
grant: GleapSession.getInstance().grant,
apiUrl: GleapSession.getInstance().apiUrl,
realtimeHost: GleapSession.getInstance().realtimeHost,
sdkKey: GleapSession.getInstance().sdkKey,
Expand Down Expand Up @@ -390,6 +399,7 @@ export default class GleapAiChatbarManager {
if (session.sdkKey) headers['Api-Token'] = session.sdkKey;
if (session.session?.gleapId) headers['Gleap-Id'] = session.session.gleapId;
if (session.session?.gleapHash) headers['Gleap-Hash'] = session.session.gleapHash;
session.addGrantHeader(headers);

const res = await fetch(`${apiUrl}/v3/shared/agents/${encodeURIComponent(agentId)}`, { headers });
if (!res.ok) return null;
Expand Down
2 changes: 2 additions & 0 deletions src/GleapFrameManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,8 @@ export default class GleapFrameManager {
name: 'session-update',
data: {
sessionData: GleapSession.getInstance().getSession(),
// Top level, never inside sessionData (the messenger persists that).
grant: GleapSession.getInstance().grant,
apiUrl: GleapSession.getInstance().apiUrl,
realtimeHost: GleapSession.getInstance().realtimeHost,
sdkKey: GleapSession.getInstance().sdkKey,
Expand Down
12 changes: 10 additions & 2 deletions src/GleapHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,11 @@ export const clearGleapCache = (key) => {
} catch {}
};

export const setGleapCookie = (name, value, days) => {
/**
* Writes a cookie on the top-level domain. `credential`: the value is a
* credential, so it gets SameSite=Lax and, on https, Secure.
*/
export const setGleapCookie = (name, value, days, credential = false) => {
try {
var expires = '';
if (days) {
Expand All @@ -667,7 +671,11 @@ export const setGleapCookie = (name, value, days) => {
expires = '; expires=' + date.toUTCString();
}
const host = removeSubDomain(window.location.host.split(':')[0]);
document.cookie = name + '=' + (value || '') + expires + '; path=/; domain=' + host;
var attributes = '';
if (credential) {
attributes = '; SameSite=Lax' + (window.location.protocol === 'https:' ? '; Secure' : '');
}
document.cookie = name + '=' + (value || '') + expires + '; path=/; domain=' + host + attributes;
} catch (exp) {}
};

Expand Down
Loading
Loading