Skip to content

Commit 1db8435

Browse files
boehlerlukasclaude
andauthored
feat: setUserJwt, setUserJwtProvider and setAuthTokens (customer identity) (#203)
* feat(identity): setUserJwt, setUserJwtProvider and setAuthTokens Customer identity (contract §2, §3, §5, §6): one HS256 JWT signed by the app's backend is the whole identify. - Gleap.setUserJwt(token) decodes the payload (no verification), takes userId / user_id and sends the existing identify with userHash = token and no contact fields. Bad shape, no user id or over 4096 chars: console warning, no request. Memory only. The once-only signed-identify record adds a fingerprint of a JWT, so a new token is sent once even when nothing else changed; legacy hashes keep the gleapId:userId key. - Gleap.setUserJwtProvider(fn) is asked on identify 401 token_expired (one retry with the new token) and before a file-access renewal or identify replay when the token expires within 5 minutes. One call in flight, 10 s timeout, 30 s pause after a failure. token_expired / token_invalid never clear the local session. - Gleap.setAuthTokens(tokens) posts to /sessions/auth-tokens; names checked against ^[a-zA-Z][a-zA-Z0-9_-]{0,39}$, null deletes, kept in memory and resent after a signed identify on a session they were not sent to; 401 warns and keeps the session. - clearIdentity drops the token and auth tokens; the provider stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(identity): README teaches the signed identity token first Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(identity): carry the signed-session grant on every request A signed identify now returns identity.grant. The SDK keeps it in memory and in the gleap-grant-<apiKey> cookie (same domain rules as the session cookie, expiring with the grant), never in the persisted session. It is restored on reload for the same session, sent as Gleap-Grant on every SDK request, pushed to the messenger and AI chatbar as a top-level grant field of session-update, and refreshed a minute before it expires. clearIdentity drops it and its cookie (the provider stays). A 400 identity_mismatch drops the local session and retries once as a fresh visitor. getIdentity returns the grant; initialize takes sessionDuration (ms), sent with identify. Gleap-Grant is masked in captured network logs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(identity): grant refresh from its lifetime, setUserJwt never rejects, signed cookie - Grant refresh: delay from the grant's own lifetime (exp - iat) counted from receipt, at least 30 s, at most one refresh identify a minute, skipped while one is in flight. A short grant or a fast device clock no longer loops. - A token re-minted per page load (same kid and claims, new iat/exp/jti) is not identified again; a changed claim or key is. - setUserJwt never rejects: refusals and network/server errors are logged. - signature_required is logged. - Gleap.setSessionDuration(ms) next to the initialize option. - setUserJwtProvider accepts null (removes it) and a provider returning null. - gleap-grant-<apiKey> cookie: SameSite=Lax, Secure on https. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(identity): signed-mode grant for returning users, logout race, refresh retries - Signed mode without a grant on an identified session: one identify per session load with the held token or hash, even when it was sent before (at most once a minute, never a loop). - An identify answer that lands after clearSession is dropped (logout generation), as is a partial update answer. - signature_required for another user than the session's clears the session. - A failed or skipped grant refresh is tried again a minute later; a failed identify is replayed when the browser comes back online. - A provider token that already expired counts as a provider failure; a refused token is not resent on every render (memory-only fingerprint). - setUserJwt before initialize decides once the cached session and its grant cookie are loaded, so a live grant costs no identify. - setUseCookies(false) erases the grant cookie. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(identity): grant for agent conversations, legacy initialize typing, changelog Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(identity): drop a grant the server answered as not signed and re-identify Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore: version 19.5.0 (customer identity) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 62c2d05 commit 1db8435

20 files changed

Lines changed: 1842 additions & 43 deletions

‎AUTHENTICATED_FILES.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# Authenticated conversation files
22

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.
3+
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.
44

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.
5+
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.
6+
7+
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.
68

79
Validation: `npm test -- --runInBand --watchman=false` (277 tests), `npm run build`. No package publishing or synchronized SDK version bump is included.

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## 19.5.0
4+
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.
5+
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.
6+
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.
7+
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.
8+
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.
9+
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.
10+
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.
11+
312
## 19.4.1
413
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.
514

‎README.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,56 @@ Gleap.setRegion("us");
4747
Gleap.initialize("SDK-TOKEN");
4848
```
4949

50+
### Identify your users
51+
52+
#### Signed identity (recommended)
53+
54+
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).
55+
56+
```
57+
// Your backend (Node, jsonwebtoken). Use an identity key from Settings > Security > Identity keys.
58+
const token = jwt.sign(
59+
{ userId, email, stripeCustomerId, exp: Math.floor(Date.now() / 1000) + 60 * 60 },
60+
SECRET,
61+
{ algorithm: 'HS256', keyid: KEY_ID }
62+
);
63+
```
64+
65+
```
66+
// Your app
67+
Gleap.setUserJwt(token);
68+
```
69+
70+
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:
71+
72+
```
73+
Gleap.setUserJwtProvider(() => fetch('/api/gleap-token').then((res) => res.text()));
74+
```
75+
76+
Per-user tokens for your API tools (used as `{{session.authTokens.<name>}}`) are stored on the signed contact; `null` removes one:
77+
78+
```
79+
Gleap.setAuthTokens({ backend: userAccessToken });
80+
```
81+
82+
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:
83+
84+
```
85+
Gleap.initialize("SDK-TOKEN", { sessionDuration: 24 * 60 * 60 * 1000 });
86+
```
87+
88+
or later with `Gleap.setSessionDuration(24 * 60 * 60 * 1000)` (the same call as in the mobile SDKs).
89+
90+
`Gleap.setUserJwt` never rejects: a refused token (`token_expired`, `token_invalid`, `signature_required`) or a network error is logged to the console.
91+
92+
`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).
93+
94+
`Gleap.clearIdentity()` on logout clears the contact, the token, the grant (and its cookie) and the auth tokens. The JWT provider stays set.
95+
96+
#### Legacy user hash
97+
98+
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.
99+
50100
### Dark mode (optional)
51101

52102
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):

‎index.d.ts‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@
22
export type GleapRegion = "eu" | "us";
33

44
export namespace Gleap {
5-
function initialize(sdkKey: string, disablePing?: boolean): void;
5+
/**
6+
* `options.sessionDuration`: how long (ms) a signed session stays signed before a new
7+
* identify is needed. The server never goes beyond the project's session max age.
8+
* A boolean second argument is the legacy `disablePing` flag and is still accepted
9+
* (the ping it controlled no longer exists, so it has no effect).
10+
*/
11+
function initialize(
12+
sdkKey: string,
13+
options?: boolean | { disablePing?: boolean; sessionDuration?: number }
14+
): void;
615
function sendSilentCrashReport(
716
description: string,
817
priority?: "LOW" | "MEDIUM" | "HIGH",
@@ -168,7 +177,35 @@ export namespace Gleap {
168177
}[]): void;
169178
function showTabNotificationBadge(showNotificationBadge: boolean): void;
170179
function attachNetworkLogs(networkLogs: string): void;
180+
/** Clears the current contact, its identity token and auth tokens. The JWT provider stays set. */
171181
function clearIdentity(): void;
182+
/**
183+
* Identifies the user with an identity token: an HS256 JWT signed by your backend.
184+
* The `userId` (or `user_id`) claim names the user; every other claim lands on the
185+
* contact as signed data. Nothing else is sent. Kept in memory only.
186+
* A token that is not a JWT, has no userId or is over 4096 characters is refused
187+
* with a console warning and no request.
188+
* Never rejects; refusals are logged.
189+
*/
190+
function setUserJwt(token: string): Promise<void>;
191+
/**
192+
* Returns a fresh identity token. Called when the current token expired, or before
193+
* a renewal when it expires within 5 minutes. One call at a time, 10 s timeout,
194+
* 30 s pause after a failure. `null` removes the provider.
195+
*/
196+
function setUserJwtProvider(provider: (() => string | null | Promise<string | null>) | null): void;
197+
/**
198+
* How long (ms) a signed session stays signed; the server never goes beyond the
199+
* project's maximum. Same as `initialize(sdkKey, { sessionDuration })`. `null` drops it.
200+
*/
201+
function setSessionDuration(sessionDuration: number | null): void;
202+
/**
203+
* Stores per-user tokens for your API tools on the signed contact
204+
* (`{{session.authTokens.<name>}}`). Names: a letter followed by up to 39 letters,
205+
* digits, `_` or `-`. A `null` value removes a token. Kept in memory only and
206+
* sent again after the next signed identify on a new session. Needs `setUserJwt`.
207+
*/
208+
function setAuthTokens(tokens: Record<string, string | null>): Promise<void>;
172209
function setTags(tags: string[]): void;
173210
/**
174211
* Hide notification cards, including cached or already-visible cards, without
@@ -198,6 +235,7 @@ export namespace Gleap {
198235
avatar?: string | null;
199236
createdAt?: Date | null;
200237
},
238+
/** The HMAC-SHA256 of the user id (legacy), or an identity token (see `setUserJwt`). */
201239
userHash?: string
202240
): void;
203241
function updateContact(
@@ -451,7 +489,11 @@ export namespace Gleap {
451489
*/
452490
function createAgentChat(options: AgentChatOptions): AgentChatInstance;
453491

454-
function getIdentity(): any;
492+
/**
493+
* The current session, including `gleapId`, `gleapHash` and `grant` (the signed-session
494+
* credential a signed identify returns; null when the session is not signed).
495+
*/
496+
function getIdentity(): { gleapId: string | null; gleapHash: string | null; grant: string | null; [key: string]: any };
455497
function isUserIdentified(): boolean;
456498
function setReplayOptions(options: {
457499
blockClass?: string | RegExp;

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"description": "JavaScript and TypeScript SDK for Gleap, an Intercom alternative with AI customer support, live chat, in-app bug reporting and customer feedback.",
33
"name": "gleap",
4-
"version": "19.4.1",
4+
"version": "19.5.0",
55
"main": "build/cjs/index.js",
66
"module": "build/esm/index.mjs",
77
"exports": {

‎src/AgentNetworkManager.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class AgentNetworkManager {
5151
if (s?.sdkKey) headers['Api-Token'] = s.sdkKey;
5252
if (s?.session?.gleapId) headers['Gleap-Id'] = s.session.gleapId;
5353
if (s?.session?.gleapHash) headers['Gleap-Hash'] = s.session.gleapHash;
54+
s?.addGrantHeader?.(headers);
5455
return headers;
5556
}
5657

‎src/Gleap.js‎

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,14 +237,22 @@ class Gleap {
237237
/**
238238
* Initializes the SDK
239239
* @param {*} sdkKey
240+
* @param {boolean | { disablePing?: boolean, sessionDuration?: number }} [options]
241+
* sessionDuration: how long (ms) a signed session stays signed; the server never goes
242+
* beyond the project's maximum. A boolean (the legacy `disablePing` argument) and
243+
* `disablePing` are accepted and have no effect: the ping no longer exists.
240244
*/
241-
static initialize(sdkKey) {
245+
static initialize(sdkKey, options) {
242246
const instance = this.getInstance();
243247
if (instance.initialized) {
244248
console.warn('Gleap already initialized.');
245249
return;
246250
}
247251

252+
if (options && typeof options === 'object' && 'sessionDuration' in options) {
253+
GleapSession.getInstance().setSessionDuration(options.sessionDuration);
254+
}
255+
248256
instance.initialized = true;
249257

250258
// Start capturing the session replay as early as possible. Capture is
@@ -459,7 +467,7 @@ class Gleap {
459467
* @param {*} useCookies
460468
*/
461469
static setUseCookies(useCookies) {
462-
GleapSession.getInstance().useCookies = useCookies;
470+
GleapSession.getInstance().setUseCookies(useCookies);
463471
}
464472

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

486+
/**
487+
* Identifies the user with an identity token (HS256 JWT signed by your backend).
488+
* The token is the whole identify: its `userId` (or `user_id`) claim names the user,
489+
* every other claim lands on the contact as signed data. Kept in memory only.
490+
* Never rejects; refusals are logged.
491+
* @param {string} token
492+
* @returns {Promise<void>}
493+
*/
494+
static setUserJwt(token) {
495+
return GleapSession.getInstance().setUserJwt(token);
496+
}
497+
498+
/**
499+
* How long (ms) a signed session stays signed; the server never goes beyond
500+
* the project's maximum. Sent with every identify. `null` drops it.
501+
* Same as `initialize(sdkKey, { sessionDuration })`.
502+
* @param {number | null} sessionDuration
503+
*/
504+
static setSessionDuration(sessionDuration) {
505+
GleapSession.getInstance().setSessionDuration(sessionDuration);
506+
}
507+
508+
/**
509+
* Sets a function that returns a fresh identity token (or a Promise of one).
510+
* Gleap calls it when the current token expired or is about to. `null` removes it.
511+
* @param {(() => (string | null | Promise<string | null>)) | null} provider
512+
*/
513+
static setUserJwtProvider(provider) {
514+
GleapSession.getInstance().setUserJwtProvider(provider);
515+
}
516+
517+
/**
518+
* Stores per-user tokens for your API tools on the signed contact
519+
* (`{{session.authTokens.<name>}}`). A `null` value removes a token. Kept in memory only.
520+
* @param {Object<string, string | null>} tokens
521+
* @returns {Promise<void>}
522+
*/
523+
static setAuthTokens(tokens) {
524+
return GleapSession.getInstance().setAuthTokens(tokens);
525+
}
526+
478527
/**
479528
* Updates the contact data.
480529
* @param {*} userData
@@ -484,17 +533,19 @@ class Gleap {
484533
}
485534

486535
/**
487-
* Clears the current contact.
536+
* Clears the current contact, its identity token and auth tokens. The JWT provider stays set.
488537
*/
489538
static clearIdentity() {
490539
GleapSession.getInstance().clearSession();
491540
}
492541

493542
/**
494-
* Returns the current user session
543+
* Returns the current user session, plus `grant`: the signed-session credential
544+
* (null when the session is not signed). Hand `{ gleapId, gleapHash, grant }` to a
545+
* help center or portal to keep the user signed in there.
495546
*/
496547
static getIdentity() {
497-
return GleapSession.getInstance().getSession();
548+
return { ...GleapSession.getInstance().getSession(), grant: GleapSession.getInstance().grant || null };
498549
}
499550

500551
/**

‎src/GleapAgentConversation.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,17 @@ export const registerGleapAgentComponents = () => {
115115
let sdkKey = '';
116116
let gleapId = '';
117117
let gleapHash = '';
118+
// The signed-session grant, from the same accessor the messenger handover
119+
// uses: a cleared identity clears it here too.
120+
let grant = null;
118121

119122
try {
120123
const session = GleapSession.getInstance();
121124
apiUrl = session.apiUrl || apiUrl;
122125
sdkKey = session.sdkKey || '';
123126
gleapId = session.session?.gleapId || '';
124127
gleapHash = session.session?.gleapHash || '';
128+
grant = session.grant || null;
125129
} catch (e) {}
126130

127131
const config = {
@@ -136,6 +140,7 @@ export const registerGleapAgentComponents = () => {
136140
sdkKey,
137141
gleapId,
138142
gleapHash,
143+
grant,
139144
};
140145

141146
this._config = config;

‎src/GleapAiChatbarManager.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,20 @@ export default class GleapAiChatbarManager {
174174
queued.forEach((m) => this._postMessageRaw(m));
175175
}
176176

177+
/** Re-sends the session (and the grant) once the frame is talking to us; before that the ping sends it. */
178+
sendSessionUpdateIfReady() {
179+
if (this.comReady) {
180+
this._sendSessionUpdate();
181+
}
182+
}
183+
177184
_sendSessionUpdate() {
178185
this._postMessageRaw({
179186
name: 'session-update',
180187
data: {
181188
sessionData: GleapSession.getInstance().getSession(),
189+
// Top level, never inside sessionData (the messenger persists that).
190+
grant: GleapSession.getInstance().grant,
182191
apiUrl: GleapSession.getInstance().apiUrl,
183192
realtimeHost: GleapSession.getInstance().realtimeHost,
184193
sdkKey: GleapSession.getInstance().sdkKey,
@@ -390,6 +399,7 @@ export default class GleapAiChatbarManager {
390399
if (session.sdkKey) headers['Api-Token'] = session.sdkKey;
391400
if (session.session?.gleapId) headers['Gleap-Id'] = session.session.gleapId;
392401
if (session.session?.gleapHash) headers['Gleap-Hash'] = session.session.gleapHash;
402+
session.addGrantHeader(headers);
393403

394404
const res = await fetch(`${apiUrl}/v3/shared/agents/${encodeURIComponent(agentId)}`, { headers });
395405
if (!res.ok) return null;

0 commit comments

Comments
 (0)