diff --git a/AUTHENTICATED_FILES.md b/AUTHENTICATED_FILES.md index d2a81495..06f55d22 100644 --- a/AUTHENTICATED_FILES.md +++ b/AUTHENTICATED_FILES.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7044c87f..7dbafb1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.}}`); `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-` 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. diff --git a/README.md b/README.md index 8da82768..348859da 100644 --- a/README.md +++ b/README.md @@ -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.}}`) 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-` 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): diff --git a/index.d.ts b/index.d.ts index eccfb39e..ba76c12e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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", @@ -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; + /** + * 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) | 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.}}`). 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): Promise; function setTags(tags: string[]): void; /** * Hide notification cards, including cached or already-visible cards, without @@ -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( @@ -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; diff --git a/package-lock.json b/package-lock.json index 9ecf55e1..c2a21376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gleap", - "version": "19.4.1", + "version": "19.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gleap", - "version": "19.4.1", + "version": "19.5.0", "license": "Commercial", "dependencies": { "@floating-ui/dom": "^1.7.5", diff --git a/package.json b/package.json index 18130d7c..a51f564c 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.4.1", + "version": "19.5.0", "main": "build/cjs/index.js", "module": "build/esm/index.mjs", "exports": { diff --git a/src/AgentNetworkManager.js b/src/AgentNetworkManager.js index a560ba8c..9651e5be 100644 --- a/src/AgentNetworkManager.js +++ b/src/AgentNetworkManager.js @@ -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; } diff --git a/src/Gleap.js b/src/Gleap.js index 40b79478..42df5c08 100755 --- a/src/Gleap.js +++ b/src/Gleap.js @@ -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 @@ -459,7 +467,7 @@ class Gleap { * @param {*} useCookies */ static setUseCookies(useCookies) { - GleapSession.getInstance().useCookies = useCookies; + GleapSession.getInstance().setUseCookies(useCookies); } static disableLocalStorage() { @@ -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} + */ + 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)) | null} provider + */ + static setUserJwtProvider(provider) { + GleapSession.getInstance().setUserJwtProvider(provider); + } + + /** + * Stores per-user tokens for your API tools on the signed contact + * (`{{session.authTokens.}}`). A `null` value removes a token. Kept in memory only. + * @param {Object} tokens + * @returns {Promise} + */ + static setAuthTokens(tokens) { + return GleapSession.getInstance().setAuthTokens(tokens); + } + /** * Updates the contact data. * @param {*} userData @@ -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 }; } /** diff --git a/src/GleapAgentConversation.js b/src/GleapAgentConversation.js index 3ba22a29..6507f75e 100644 --- a/src/GleapAgentConversation.js +++ b/src/GleapAgentConversation.js @@ -115,6 +115,9 @@ 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(); @@ -122,6 +125,7 @@ export const registerGleapAgentComponents = () => { sdkKey = session.sdkKey || ''; gleapId = session.session?.gleapId || ''; gleapHash = session.session?.gleapHash || ''; + grant = session.grant || null; } catch (e) {} const config = { @@ -136,6 +140,7 @@ export const registerGleapAgentComponents = () => { sdkKey, gleapId, gleapHash, + grant, }; this._config = config; diff --git a/src/GleapAiChatbarManager.js b/src/GleapAiChatbarManager.js index a33f6ceb..08e29e3a 100644 --- a/src/GleapAiChatbarManager.js +++ b/src/GleapAiChatbarManager.js @@ -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, @@ -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; diff --git a/src/GleapFrameManager.js b/src/GleapFrameManager.js index 575ab517..34f90f7c 100644 --- a/src/GleapFrameManager.js +++ b/src/GleapFrameManager.js @@ -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, diff --git a/src/GleapHelper.js b/src/GleapHelper.js index 1b9c3596..48f9afa6 100644 --- a/src/GleapHelper.js +++ b/src/GleapHelper.js @@ -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) { @@ -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) {} }; diff --git a/src/GleapHelper.test.js b/src/GleapHelper.test.js index 956bbb9a..9377f40f 100644 --- a/src/GleapHelper.test.js +++ b/src/GleapHelper.test.js @@ -1,4 +1,4 @@ -import { flattenCompany, formatRelativeTime } from './GleapHelper'; +import { flattenCompany, formatRelativeTime, setGleapCookie } from './GleapHelper'; describe('formatRelativeTime — the notification meta line', () => { const agoBy = (ms) => new Date(Date.now() - ms).toISOString(); @@ -288,3 +288,27 @@ describe('flattenCompany — must never mutate the caller and never throw', () = expect(flattenCompany({ company })).toEqual({ companyId: 'acme-1', companyName: 'Acme' }); }); }); + +describe('setGleapCookie', () => { + const written = (protocol, credential) => { + const previous = { window: global.window, document: global.document }; + global.window = { location: { host: 'app.example.com', protocol } }; + global.document = { cookie: '' }; + setGleapCookie('name', 'value', 1, credential); + const cookie = global.document.cookie; + global.window = previous.window; + global.document = previous.document; + return cookie; + }; + + test('a credential cookie gets SameSite=Lax, and Secure on https', () => { + expect(written('https:', true)).toMatch(/; SameSite=Lax; Secure$/); + expect(written('http:', true)).toMatch(/; SameSite=Lax$/); + }); + + test('other cookies keep their attributes', () => { + const cookie = written('https:', false); + expect(cookie).not.toContain('SameSite'); + expect(cookie).not.toContain('Secure'); + }); +}); diff --git a/src/GleapNetworkLogSanitizer.js b/src/GleapNetworkLogSanitizer.js index 2700e903..94db8e4c 100644 --- a/src/GleapNetworkLogSanitizer.js +++ b/src/GleapNetworkLogSanitizer.js @@ -6,9 +6,9 @@ export const DEFAULT_NETWORK_LOG_BLACKLIST = ['gleap.io', 'gleap.ai']; export const REDACTED_VALUE = '[REDACTED]'; export const BODY_NOT_CAPTURED = '[body not captured]'; -// Credentials are masked (key kept, value replaced) whatever the project settings say. Gleap-Hash is -// the widget session's credential (on the SDK's own calls through a custom api url). -const ALWAYS_MASKED_HEADERS = ['authorization', 'proxy-authorization', 'cookie', 'set-cookie', 'gleap-hash']; +// Credentials are masked (key kept, value replaced) whatever the project settings say. Gleap-Hash and +// Gleap-Grant are the widget session's credentials (on the SDK's own calls through a custom api url). +const ALWAYS_MASKED_HEADERS = ['authorization', 'proxy-authorization', 'cookie', 'set-cookie', 'gleap-hash', 'gleap-grant']; // Guards the recursive walks against pathological nesting. A body nested deeper than this is // dropped instead of being sent unredacted. diff --git a/src/GleapNetworkLogSanitizer.test.js b/src/GleapNetworkLogSanitizer.test.js index 867e5bf6..83bae5a1 100644 --- a/src/GleapNetworkLogSanitizer.test.js +++ b/src/GleapNetworkLogSanitizer.test.js @@ -268,10 +268,15 @@ describe('custom api url (Gleap.setApiUrl)', () => { expect(intercepter.getBlacklist()).toEqual(['gleap.io', 'gleap.ai']); }); - test('masks the widget session credential (Gleap-Hash) on any request', () => { + test('masks the widget session credentials (Gleap-Hash, Gleap-Grant) on any request', () => { const log = entry({ - request: { headers: { 'Gleap-Hash': 'h-123', 'Gleap-Id': 'g-1', Accept: '*/*' }, payload: '' }, + request: { headers: { 'Gleap-Hash': 'h-123', 'Gleap-Grant': 'g.r.t', 'Gleap-Id': 'g-1', Accept: '*/*' }, payload: '' }, + }); + expect(sanitizeOne(log).request.headers).toEqual({ + 'Gleap-Hash': REDACTED_VALUE, + 'Gleap-Grant': REDACTED_VALUE, + 'Gleap-Id': 'g-1', + Accept: '*/*', }); - expect(sanitizeOne(log).request.headers).toEqual({ 'Gleap-Hash': REDACTED_VALUE, 'Gleap-Id': 'g-1', Accept: '*/*' }); }); }); diff --git a/src/GleapSession.js b/src/GleapSession.js index 352c5ddc..c801ff68 100644 --- a/src/GleapSession.js +++ b/src/GleapSession.js @@ -18,7 +18,25 @@ import { setGleapCookie, } from './GleapHelper'; import GleapTooltipManager from './GleapTooltipManager'; +import GleapAiChatbarManager from './GleapAiChatbarManager'; import { GLEAP_DEFAULT_REGION, GLEAP_REGIONS, resolveGleapRegion } from './GleapRegions'; +import { decodeGrant, decodeUserJwt, fingerprintOf, identityFingerprintOf, identifyRefusalOf, isUserJwt } from './GleapUserJwt'; + +const AUTH_TOKEN_NAME = /^[a-zA-Z][a-zA-Z0-9_-]{0,39}$/; +const JWT_PROVIDER_TIMEOUT_MS = 10 * 1000; +const JWT_PROVIDER_BACKOFF_MS = 30 * 1000; +const JWT_REFRESH_WINDOW_MS = 5 * 60 * 1000; +// Cookie lifetime for a grant without an exp claim (the server's default session max age). +const GRANT_FALLBACK_LIFETIME_DAYS = 7; +// Grant refresh: a minute before the grant's own lifetime runs out, never +// sooner than 30 s after it arrived, at most one refresh identify a minute. +const GRANT_REFRESH_MARGIN_MS = 60 * 1000; +const GRANT_REFRESH_MIN_DELAY_MS = 30 * 1000; +const GRANT_REFRESH_MIN_INTERVAL_MS = 60 * 1000; +// A refresh that failed or could not run yet is tried again after this pause. +const GRANT_REFRESH_RETRY_MS = 60 * 1000; +const SIGNATURE_REQUIRED_WARNING = + 'Gleap: this project enforces identity verification and the identify had no valid signature (signature_required). Pass a token from your backend to setUserJwt.'; export default class GleapSession { region = GLEAP_DEFAULT_REGION; @@ -56,8 +74,19 @@ export default class GleapSession { // before (an older app version, identity verification switched on later, a // sign-up identified before it could get one) still has to send the hash // once: only that request lets the server mark the contact as proved. - // Keyed by user, never by the hash itself, so no credential is stored. + // Keyed by user, never by the hash itself, so no credential is stored. An + // identity token (JWT) adds a fingerprint of the token, so a new token is + // sent once even when the user and the data are unchanged. signedIdentity = null; + // Gleap.setUserJwtProvider: asked for a fresh identity token when the stored + // one expired or is about to. One call at a time, then a pause after a failure. + jwtProvider = null; + jwtProviderRequest = null; + jwtProviderFailedAt = 0; + // Gleap.setAuthTokens: memory only; resent when a signed identify lands on + // a session they were not sent to yet. + authTokens = {}; + authTokensSentFor = null; // identify / updateContact requests still waiting for the server. A survey // started meanwhile would get the previous contact (Surveys 2.0 skips the // contact questions an identified contact already answered), so showSurvey @@ -67,6 +96,43 @@ export default class GleapSession { // Bumped whenever an identify / updateContact result (or a logout) replaces the session. A // /sessions response for a request sent before that describes the previous contact and is dropped. contactVersion = 0; + // The grant: the server-signed session credential a signed identify returns + // (`identity.grant`). Sent as `Gleap-Grant` on every request and handed to + // the messenger next to (never inside) the session. Kept out of `session`, + // which is persisted to localStorage; stored only in the + // `gleap-grant-` cookie (when cookies are on) so a reload and a + // help center on the same domain stay signed. + grant = null; + grantRefreshTimeout = null; + // When the current grant is due for a refresh (ms, device clock), when the + // last refresh identify went out, and whether one is still running. + grantRefreshAt = null; + lastGrantRefreshAt = 0; + grantRefreshInFlight = false; + // How the last identify failed ('refused' when a refusal was logged, else + // 'network error' / 'HTTP '), for setUserJwt's log line. + lastIdentifyFailure = null; + // Gleap.initialize(sdkKey, { sessionDuration }): asks the server for a + // shorter grant (ms). Sent with every identify. + sessionDuration = null; + // Bumped only by clearSession (logout, destroy, rejected session). An + // identify answer sent under an older generation is dropped, so a late + // answer cannot bring back the user who just logged out. + logoutGeneration = 0; + // Signed mode (`identity.mode === 'signed'`) on an identified session + // without a live grant: one identify per session load fetches the grant, + // even when this exact identify was sent before (a returning user whose + // project switched to Signed after they were identified). + signedModeIdentifySent = false; + lastSignedModeIdentifyAt = 0; + // An identify that failed on the network or with a server error: replayed + // once the browser comes back online. + identifyFailedTransiently = false; + onlineListenerAdded = false; + // Fingerprint of the last userId + token/hash the server refused (memory + // only, never the token itself): an app that identifies on every render + // does not send the same refused token again and again. + refusedIdentity = null; // GleapSession singleton static instance; @@ -214,9 +280,214 @@ export default class GleapSession { http.setRequestHeader('Api-Token', this.sdkKey); http.setRequestHeader('Gleap-Id', this.session.gleapId); http.setRequestHeader('Gleap-Hash', this.session.gleapHash); + this.injectGrant(http); } }; + /** Adds the `Gleap-Grant` header to an XHR, when there is a grant. */ + injectGrant = (http) => { + if (http && this.grant) { + try { + http.setRequestHeader('Gleap-Grant', this.grant); + } catch (exp) {} + } + }; + + /** Adds the `Gleap-Grant` entry to a fetch headers object, when there is a grant. */ + addGrantHeader = (headers) => { + if (headers && this.grant) { + headers['Gleap-Grant'] = this.grant; + } + return headers; + }; + + grantCookieName = () => `gleap-grant-${this.sdkKey}`; + + /** + * Replaces the grant (null drops it), writes or erases its cookie and, when + * it changed and `push` is set, hands it to the messenger frames. + */ + setGrant = (grant, push = true) => { + const next = typeof grant === 'string' && grant.length > 0 ? grant : null; + if (next === this.grant) { + return; + } + this.grant = next; + if (this.useCookies) { + try { + if (next) { + const decoded = decodeGrant(next); + const days = + decoded && decoded.exp !== null + ? (decoded.exp * 1000 - Date.now()) / (24 * 60 * 60 * 1000) + : GRANT_FALLBACK_LIFETIME_DAYS; + if (days > 0) { + setGleapCookie(this.grantCookieName(), encodeURIComponent(next), days, true); + } else { + eraseGleapCookie(this.grantCookieName()); + } + } else { + eraseGleapCookie(this.grantCookieName()); + } + } catch (exp) {} + } + this.scheduleGrantRefresh(next ? Date.now() : null); + if (push) { + this.pushSessionUpdate(); + } + }; + + /** True while the grant's exp lies in the future (or it has none). */ + hasLiveGrant = () => { + if (!this.grant) { + return false; + } + const decoded = decodeGrant(this.grant); + return !!decoded && (decoded.exp === null || decoded.exp * 1000 > Date.now()); + }; + + /** + * When to renew a grant: a minute before its own lifetime (exp - iat) runs + * out, counted from `receivedAt` on the device clock, so a skewed clock + * cannot make it due at once. Never sooner than 30 s after receipt. A grant + * restored from its cookie has no receipt time: the device clock decides, + * still at least 30 s out. Null when the grant never expires. + */ + grantRefreshTime = (decoded, receivedAt) => { + if (!decoded || decoded.exp === null) { + return null; + } + const now = Date.now(); + const remaining = + receivedAt && decoded.iat !== null && decoded.exp > decoded.iat + ? (decoded.exp - decoded.iat) * 1000 - (now - receivedAt) + : decoded.exp * 1000 - now; + return now + Math.max(GRANT_REFRESH_MIN_DELAY_MS, remaining - GRANT_REFRESH_MARGIN_MS); + }; + + /** + * Schedules sending the last signed identify again (through the JWT provider + * when its token expires soon) for a new grant. `receivedAt`: when the + * server handed the grant out (Date.now()), or null for a restored one. + */ + scheduleGrantRefresh = (receivedAt = null) => { + clearTimeout(this.grantRefreshTimeout); + this.grantRefreshTimeout = null; + this.grantRefreshAt = this.grantRefreshTime(this.grant ? decodeGrant(this.grant) : null, receivedAt); + if (this.grantRefreshAt !== null) { + this.armGrantRefresh(this.grant, this.grantRefreshAt); + } + }; + + armGrantRefresh = (grant, at) => { + clearTimeout(this.grantRefreshTimeout); + // setTimeout overflows above ~24.8 days; the callback re-arms until `at`. + const delay = Math.min(Math.max(0, at - Date.now()), 2147483647); + this.grantRefreshTimeout = setTimeout(() => { + this.grantRefreshTimeout = null; + if (this.grant !== grant) { + return; + } + const now = Date.now(); + if (at - now > 1000) { + this.armGrantRefresh(grant, at); + return; + } + // One refresh at a time; the identify already on its way brings the next + // grant. Should it fail, the grant is looked at again in a minute. + if (this.grantRefreshInFlight || this.identifyInFlight) { + this.armGrantRefresh(grant, now + GRANT_REFRESH_RETRY_MS); + return; + } + if (now - this.lastGrantRefreshAt < GRANT_REFRESH_MIN_INTERVAL_MS) { + this.armGrantRefresh(grant, this.lastGrantRefreshAt + GRANT_REFRESH_MIN_INTERVAL_MS); + return; + } + // Nothing to send (logged out, or identified without a token or hash). + if (!this.lastIdentify || !this.lastIdentify.userHash) { + return; + } + this.lastGrantRefreshAt = now; + this.grantRefreshInFlight = true; + const done = () => { + this.grantRefreshInFlight = false; + // Failed, or answered without a new grant: try again in a minute. + // A new grant has armed its own refresh (setGrant), a dropped one needs none. + if (this.grant === grant && !this.grantRefreshTimeout) { + this.armGrantRefresh(grant, Date.now() + GRANT_REFRESH_RETRY_MS); + } + }; + Promise.resolve( + this.withFreshIdentify(({ userId, userData, userHash }) => this.identifySession(userId, userData, userHash, true)) + ).then(done, done); + }, delay); + }; + + /** Sends the session (and the grant) to the widget and the AI chatbar frames. */ + pushSessionUpdate = () => { + try { + GleapFrameManager.getInstance().sendSessionUpdate(); + } catch (exp) {} + try { + // Only a chatbar that exists: getInstance() would create one. + GleapAiChatbarManager.instance?.sendSessionUpdateIfReady(); + } catch (exp) {} + }; + + /** Gleap.setUseCookies: turning cookies off also erases a grant cookie written before. */ + setUseCookies = (useCookies) => { + this.useCookies = useCookies; + if (!useCookies) { + this.eraseGrantCookie(); + } + }; + + eraseGrantCookie = () => { + if (!this.sdkKey) { + return; + } + try { + eraseGleapCookie(this.grantCookieName()); + } catch (exp) {} + }; + + /** A page reload: picks the grant up from its cookie when it belongs to the current session. */ + restoreGrant = () => { + if (!this.useCookies) { + // setUseCookies(false) before initialize: a cookie from an earlier visit must not linger. + this.eraseGrantCookie(); + return; + } + if (this.grant || !this.session || !this.session.gleapId) { + return; + } + try { + const cookie = getGleapCookie(this.grantCookieName()); + if (!cookie) { + return; + } + const grant = decodeURIComponent(cookie); + const decoded = decodeGrant(grant); + if (decoded && decoded.sid === this.session.gleapId) { + this.grant = grant; + this.scheduleGrantRefresh(null); + } + } catch (exp) {} + }; + + /** + * Takes `identity.grant` out of a session response so it never lands in the + * persisted session. Returns `{ session, grant }`; `grant` is undefined when + * the response says nothing about it. + */ + extractGrant = (session) => { + if (!session || !session.identity || typeof session.identity !== 'object' || !('grant' in session.identity)) { + return { session, grant: undefined }; + } + const { grant, ...identity } = session.identity; + return { session: { ...session, identity }, grant: grant || null }; + }; + clearSession = (attemp = 0, retry = true) => { clearTimeout(this.fileRefreshTimeout); this.openingProtectedFile = null; @@ -257,7 +528,13 @@ export default class GleapSession { this.lastIdentify = null; this.identifyInFlight = false; this.forgetSignedIdentity(); + this.authTokens = {}; + this.authTokensSentFor = null; + this.setGrant(null, false); this.contactVersion++; + this.logoutGeneration++; + this.signedModeIdentifySent = false; + this.identifyFailedTransiently = false; GleapFrameManager.getInstance().sendMessage( { @@ -315,6 +592,34 @@ export default class GleapSession { return; } + const extracted = this.extractGrant(session); + session = extracted.session; + if (extracted.grant !== undefined) { + this.setGrant(extracted.grant, false); + } + // Signed mode answering "not signed" while a grant was sent: the server + // rejected it (secret rotation, region move, revoked key) although its own + // exp still looks live. Drop it (memory + cookie) so the answer's caller + // re-identifies with the held token instead of sending a dead grant until + // it expires locally. + const answeredIdentity = session.identity; + if ( + this.grant && + answeredIdentity && + typeof answeredIdentity === 'object' && + answeredIdentity.mode === 'signed' && + answeredIdentity.signed === false + ) { + this.setGrant(null, false); + } + // A grant names its session (`sid`): another session never inherits it. + if (this.grant) { + const decodedGrant = decodeGrant(this.grant); + if (!decodedGrant || decodedGrant.sid !== session.gleapId) { + this.setGrant(null, false); + } + } + // 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 && @@ -340,14 +645,16 @@ export default class GleapSession { this.session = session; this.ready = true; + // A cached session: its grant cookie is read before the session-ready + // listeners run, so an identify queued before initialize sees it. + this.restoreGrant(); 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.withFreshIdentify(({ userId, userData, userHash }) => { this.identifySession(userId, userData, userHash, true)?.catch?.(() => {}); - } + }); }, Math.max(0, Date.parse(session.fileAccessExpiresAt) - Date.now() - 5 * 60 * 1000)); } @@ -387,8 +694,9 @@ export default class GleapSession { 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?.(() => {}); + this.withFreshIdentify(({ userId, userData, userHash }) => { + this.identifySession(userId, userData, userHash, true)?.catch?.(() => {}); + }); } }; @@ -411,15 +719,176 @@ export default class GleapSession { return; } - const { userId, userData, userHash } = this.lastIdentify; - const result = this.identifySession(userId, userData, userHash); - if (result && typeof result.catch === 'function') { - result.catch(() => {}); + this.withFreshIdentify(({ userId, userData, userHash }) => { + if (this.identifyInFlight || (this.session && this.session.userId)) { + return; + } + this.identifySession(userId, userData, userHash)?.catch?.(() => {}); + }); + } catch (exp) {} + }; + + /** Listens for the browser coming back online (once per instance). */ + listenForOnline = () => { + if (this.onlineListenerAdded) { + return; + } + try { + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('online', this.handleOnline); + this.onlineListenerAdded = true; } } catch (exp) {} }; + /** Back online: sends an identify again that failed on the network or with a server error. */ + handleOnline = () => { + if (!this.identifyFailedTransiently || this.identifyInFlight || !this.lastIdentify) { + return; + } + this.identifyFailedTransiently = false; + try { + Promise.resolve( + this.withFreshIdentify(({ userId, userData, userHash }) => { + if (this.identifyInFlight) { + return; + } + return this.trackContactUpdate(this.sendIdentify(userId, userData, userHash, true)); + }) + ).catch(() => {}); + } catch (exp) {} + }; + + /** + * Signed mode on an identified session without a live grant (none, expired, + * or dropped because the server answered "not signed"), with a token + * or hash at hand and no identify for it this session load yet (at most one + * a minute). + */ + needsSignedModeIdentify = () => { + if (this.signedModeIdentifySent || this.identifyInFlight) { + 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; + }; + + markSignedModeIdentify = () => { + this.signedModeIdentifySent = true; + this.lastSignedModeIdentifyAt = Date.now(); + }; + + /** A /sessions answer in signed mode without a grant: sends the held identify once for one. */ + identifyForSignedModeIfNeeded = () => { + if (!this.needsSignedModeIdentify()) { + return; + } + this.markSignedModeIdentify(); + try { + Promise.resolve( + this.withFreshIdentify(({ userId, userData, userHash }) => + this.trackContactUpdate(this.sendIdentify(userId, userData, userHash, true)) + ) + ).catch(() => {}); + } catch (exp) {} + }; + + /** Memory-only fingerprint of a refused identify (never the token itself). */ + refusedIdentityOf = (userId, userHash) => `${userId}:${userHash ? fingerprintOf(userHash) : ''}`; + + /** + * Runs `send` with the last identify. When it carries an identity token + * that expires within 5 minutes and a JWT provider is set, asks the + * provider first and sends the fresh token instead. Synchronous otherwise. + * Returns what `send` returned (a Promise of it after a provider call). + */ + withFreshIdentify = (send) => { + const identify = this.lastIdentify; + if (!identify) { + return; + } + const token = decodeUserJwt(identify.userHash); + const expiresSoon = token && token.exp !== null && token.exp * 1000 - Date.now() < JWT_REFRESH_WINDOW_MS; + if (!expiresSoon || !this.jwtProvider) { + return send(identify); + } + return this.requestUserJwt().then((fresh) => { + // A logout or another identify meanwhile wins. + if (this.lastIdentify !== identify) { + return; + } + return send(fresh ? { ...identify, userId: decodeUserJwt(fresh).userId, userHash: fresh } : identify); + }); + }; + + setUserJwtProvider = (provider) => { + if (provider !== null && typeof provider !== 'function') { + console.warn('Gleap: setUserJwtProvider expects a function returning a token (or a Promise of one).'); + return; + } + this.jwtProvider = provider; + this.jwtProviderFailedAt = 0; + }; + + /** + * Asks the JWT provider for a fresh identity token. Resolves with the token, + * or null (no provider, failure, timeout, or still pausing after a failure). + * Never rejects. + */ + requestUserJwt = () => { + if (!this.jwtProvider) { + return Promise.resolve(null); + } + if (this.jwtProviderRequest) { + return this.jwtProviderRequest; + } + if (this.jwtProviderFailedAt && Date.now() - this.jwtProviderFailedAt < JWT_PROVIDER_BACKOFF_MS) { + return Promise.resolve(null); + } + + const provider = this.jwtProvider; + let timer = null; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('timed out after 10 s')), JWT_PROVIDER_TIMEOUT_MS); + }); + const request = Promise.race([Promise.resolve().then(() => provider()), timeout]) + .then((token) => { + if (token === null || token === undefined) { + // No token right now (logged out): no warning, same pause as a failure. + this.jwtProviderFailedAt = Date.now(); + return null; + } + const decoded = decodeUserJwt(token); + if (!decoded) { + throw new Error('it did not return a JWT with a userId'); + } + if (decoded.exp !== null && decoded.exp * 1000 <= Date.now()) { + throw new Error('it returned an expired token'); + } + this.jwtProviderFailedAt = 0; + return token; + }) + .catch((error) => { + this.jwtProviderFailedAt = Date.now(); + console.warn(`Gleap: the JWT provider failed (${error && error.message ? error.message : error}). Asking again in 30 s at the earliest.`); + return null; + }) + .finally(() => { + clearTimeout(timer); + this.jwtProviderRequest = null; + }); + this.jwtProviderRequest = request; + return request; + }; + startSession = (attemp = 0) => { + this.listenForOnline(); // Check if we already have a session cookie. try { if (this.useCookies) { @@ -438,6 +907,7 @@ export default class GleapSession { this.validateSession(cachedSession); } } + this.restoreGrant(); const self = this; const contactVersionAtSend = this.contactVersion; @@ -449,6 +919,7 @@ export default class GleapSession { if (this.session && this.session.gleapId && this.session.gleapHash) { http.setRequestHeader('Gleap-Id', this.session.gleapId); http.setRequestHeader('Gleap-Hash', this.session.gleapHash); + this.injectGrant(http); } } catch (exp) {} http.onreadystatechange = function (e) { @@ -463,6 +934,7 @@ export default class GleapSession { try { const sessionData = JSON.parse(http.responseText); self.validateSession(sessionData); + self.identifyForSignedModeIfNeeded(); } catch (exp) {} } else { if (http.status !== 429) { @@ -544,8 +1016,8 @@ export default class GleapSession { } this.onSessionReadyListener = []; - // Send session update to frame. - GleapFrameManager.getInstance().sendSessionUpdate(); + // Send session update (and the grant) to the frames. + this.pushSessionUpdate(); } checkIfSessionNeedsUpdate = (userId, userData) => { @@ -613,13 +1085,19 @@ export default class GleapSession { try { http.setRequestHeader('Gleap-Id', self.session.gleapId); http.setRequestHeader('Gleap-Hash', self.session.gleapHash); + self.injectGrant(http); } catch (exp) {} + const generationAtSend = self.logoutGeneration; http.onerror = () => { reject(); }; http.onreadystatechange = function (e) { if (http.readyState === 4) { + // A logout meanwhile: this answer describes the previous contact. + if (self.logoutGeneration !== generationAtSend) { + return reject(); + } if (http.status === 200 || http.status === 201) { try { const sessionData = JSON.parse(http.responseText); @@ -654,13 +1132,19 @@ export default class GleapSession { signedIdentityKey = () => `signed-identify-${this.sdkKey}`; - signedIdentityOf = (gleapId, userId) => `${gleapId}:${userId}`; + // An identity token adds a fingerprint of what it says (kid + claims without + // iat/exp/nbf/jti): a token re-minted per page load for the same user and + // data is not sent again, a changed claim is. + signedIdentityOf = (gleapId, userId, userHash) => + isUserJwt(userHash) ? `${gleapId}:${userId}:${identityFingerprintOf(userHash)}` : `${gleapId}:${userId}`; - rememberSignedIdentity = (sessionData, userId) => { + // `granted`: the identify returned a grant, so a later identify without one + // (cookie gone, grant expired) has to be sent again to get a new one. + rememberSignedIdentity = (sessionData, userId, userHash, granted = false) => { if (!sessionData || !sessionData.gleapId || userId === undefined || userId === null) { return; } - this.signedIdentity = this.signedIdentityOf(sessionData.gleapId, userId); + this.signedIdentity = this.signedIdentityOf(sessionData.gleapId, userId, userHash) + (granted ? '#grant' : ''); if (!this.localStorageDisabled) { try { saveToGleapCache(this.signedIdentityKey(), this.signedIdentity); @@ -685,22 +1169,63 @@ export default class GleapSession { this.signedIdentity = loadFromGleapCache(this.signedIdentityKey()); } catch (exp) {} } - return this.signedIdentity !== this.signedIdentityOf(this.session.gleapId, userId); + const expected = this.signedIdentityOf(this.session.gleapId, userId, userHash); + if (this.signedIdentity === expected + '#grant') { + return !this.hasLiveGrant(); + } + return this.signedIdentity !== expected; }; 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 identify = { userId, userData, userHash }; + this.lastIdentify = identify; + + // Called before initialize: decide once the (cached) session and its + // grant are loaded, so a live cached grant does not cost an identify. + if (!this.ready && !(this.session && this.session.gleapId)) { + return this.trackContactUpdate( + new Promise((resolve, reject) => { + this.setOnSessionReady(() => { + // A newer identify or a logout meanwhile wins. + if (this.lastIdentify !== identify) { + return resolve(); + } + Promise.resolve(this.identifySession(userId, userData, userHash, refreshFileSession)).then(resolve, reject); + }); + }) + ); + } + + // The server refused exactly this token or hash before: not again on every render. + if (this.refusedIdentity !== null && this.refusedIdentity === this.refusedIdentityOf(userId, userHash)) { + return; + } const sessionNeedsUpdate = this.checkIfSessionNeedsUpdate(userId, userData); const needsFileIdentity = !!userHash && this.session?.authenticatedFilesRequired && (!this.session?.fileAccessToken || Date.parse(this.session.fileAccessExpiresAt) <= Date.now()); - if (!sessionNeedsUpdate && !refreshFileSession && !needsFileIdentity && !this.needsSignedIdentify(userId, userHash)) { + const signedMode = this.needsSignedModeIdentify(); + if (!sessionNeedsUpdate && !refreshFileSession && !needsFileIdentity && !signedMode && !this.needsSignedIdentify(userId, userHash)) { return; } + if (signedMode) { + this.markSignedModeIdentify(); + } + + return this.trackContactUpdate(this.sendIdentify(userId, userData, userHash, true)); + }; + /** + * POST /sessions/identify. An expired identity token is renewed through the + * JWT provider and the request retried once (`renewExpiredJwt`). A token for + * another user than the session's (400 identity_mismatch) drops the local + * session and retries once as a fresh visitor (`restartOnMismatch`), which + * the server merges into the token's contact. + */ + sendIdentify = (userId, userData, userHash, renewExpiredJwt, restartOnMismatch = true) => { const self = this; - return this.trackContactUpdate(new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { // Wait for gleap session to be ready. this.setOnSessionReady(function () { if (!self.session.gleapId || !self.session.gleapHash) { @@ -714,30 +1239,105 @@ export default class GleapSession { try { http.setRequestHeader('Gleap-Id', self.session.gleapId); http.setRequestHeader('Gleap-Hash', self.session.gleapHash); + self.injectGrant(http); } catch (exp) {} self.identifyInFlight = true; + self.lastIdentifyFailure = null; + const generationAtSend = self.logoutGeneration; + // A logout (clearSession) since this identify went out: its answer is + // for the user who logged out and is dropped. + const loggedOutMeanwhile = () => { + if (self.logoutGeneration === generationAtSend) { + return false; + } + self.lastIdentifyFailure = 'superseded'; + return true; + }; http.onerror = () => { + if (loggedOutMeanwhile()) { + return reject(); + } self.identifyInFlight = false; + self.lastIdentifyFailure = 'network error'; + self.identifyFailedTransiently = true; reject(); }; http.onreadystatechange = function (e) { if (http.readyState === 4) { + if (loggedOutMeanwhile()) { + return reject(); + } self.identifyInFlight = false; if (http.status === 200 || http.status === 201) { + self.identifyFailedTransiently = false; try { const sessionData = JSON.parse(http.responseText); self.contactVersion++; if (userHash) { - self.rememberSignedIdentity(sessionData, userId); + self.rememberSignedIdentity(sessionData, userId, userHash, !!(sessionData && sessionData.identity && sessionData.identity.grant)); } self.validateSession(sessionData); + if (userHash) { + self.resendAuthTokensIfNeeded(); + } resolve(sessionData); } catch (exp) { reject(exp); } } else { + // An identity token refusal never clears the session (contract §3). + const refusal = identifyRefusalOf(http.status, http.responseText); + if (refusal === 'token_expired' && renewExpiredJwt && self.jwtProvider) { + resolve( + self.requestUserJwt().then((token) => { + // No token, or a logout / another identify meanwhile: nothing to retry. + if (!token || !self.lastIdentify || self.lastIdentify.userHash !== userHash) { + if (!token) { + console.warn('Gleap: the identity token has expired and the JWT provider returned no fresh one.'); + } + self.lastIdentifyFailure = 'refused'; + return Promise.reject(); + } + const renewedUserId = decodeUserJwt(token).userId; + self.lastIdentify = { userId: renewedUserId, userData, userHash: token }; + return self.sendIdentify(renewedUserId, userData, token, false, restartOnMismatch); + }) + ); + return; + } + if (refusal === 'identity_mismatch' && restartOnMismatch && self.lastIdentify && self.lastIdentify.userHash === userHash) { + resolve(self.restartAsVisitorAndIdentify(userId, userData, userHash, renewExpiredJwt)); + return; + } + if (refusal === 'identity_mismatch') { + console.warn('Gleap: the identity token names another user than this session, and a fresh session was refused too (identity_mismatch).'); + } + if (refusal === 'token_expired') { + console.warn('Gleap: the identity token has expired. Pass a fresh one to Gleap.setUserJwt, or set Gleap.setUserJwtProvider.'); + } else if (refusal === 'token_invalid') { + console.warn('Gleap: the identity token was refused (token_invalid): check the signing key and the userId claim.'); + } else if (refusal === 'signature_required') { + console.warn(SIGNATURE_REQUIRED_WARNING); + } + if (refusal === 'token_expired' || refusal === 'token_invalid' || refusal === 'signature_required') { + self.refusedIdentity = self.refusedIdentityOf(userId, userHash); + } + // Another user than the session's was refused: they must not keep + // the previous user's session. Start over as a visitor. + if ( + refusal === 'signature_required' && + self.session && + self.session.userId && + String(self.session.userId) !== String(userId) + ) { + self.clearSession(0, true); + } + if (!refusal && (http.status === 0 || http.status >= 500)) { + self.identifyFailedTransiently = true; + } + self.lastIdentifyFailure = refusal ? 'refused' : `HTTP ${http.status}`; reject(); } } @@ -760,13 +1360,144 @@ export default class GleapSession { ...dataToSend, userId, userHash, + ...(self.sessionDuration ? { sessionDuration: self.sessionDuration } : {}), lang: GleapTranslationManager.getInstance().getActiveLanguage(), deviceType: getDeviceType(), platform: 'web', }) ); }); - })); + }); + }; + + /** + * identity_mismatch: like clearIdentity (session, grant, cookies gone; the + * provider stays), but the pending identify and the auth tokens are kept, + * then a fresh visitor session is started and the identify sent once more. + */ + restartAsVisitorAndIdentify = (userId, userData, userHash, renewExpiredJwt) => { + const pendingIdentify = this.lastIdentify; + const pendingAuthTokens = { ...this.authTokens }; + this.clearSession(0, false); + this.lastIdentify = pendingIdentify; + this.authTokens = pendingAuthTokens; + // Queued before the new session exists, so it runs as soon as it is ready + // (and replayIdentifyIfNeeded then sees the identify in flight). + const retry = this.sendIdentify(userId, userData, userHash, renewExpiredJwt, false); + this.startSession(); + return retry; + }; + + /** Gleap.setSessionDuration / initialize(sdkKey, { sessionDuration }): a positive number of milliseconds, or null to drop it. */ + setSessionDuration = (sessionDuration) => { + if (sessionDuration === undefined || sessionDuration === null) { + this.sessionDuration = null; + return; + } + if (typeof sessionDuration !== 'number' || !isFinite(sessionDuration) || sessionDuration <= 0) { + console.warn('Gleap: sessionDuration expects a positive number of milliseconds. Ignored.'); + return; + } + this.sessionDuration = Math.round(sessionDuration); + }; + + setUserJwt = (token) => { + const decoded = decodeUserJwt(token); + if (!decoded) { + console.warn( + 'Gleap: setUserJwt expects a JWT (three base64url parts, at most 4096 characters) whose payload has a userId (or user_id). Nothing was sent.' + ); + return Promise.resolve(); + } + // Never rejects: a refusal is logged where it is read, anything else here. + let sent; + try { + sent = Promise.resolve(this.identifySession(decoded.userId, {}, token)); + } catch (error) { + sent = Promise.reject(error); + } + return sent + .then( + () => {}, + (error) => { + if (this.lastIdentifyFailure === 'refused' || this.lastIdentifyFailure === 'superseded') { + return; + } + const reason = error ? (error.message || error) : this.lastIdentifyFailure || 'unknown error'; + console.warn(`Gleap: setUserJwt could not identify the user (${reason}). Nothing changed; call it again later.`); + } + ); + }; + + /** + * Gleap.setAuthTokens: stores per-user tokens for the project's API tools on + * the signed contact. `null` removes a token. + */ + setAuthTokens = (tokens) => { + if (!tokens || typeof tokens !== 'object' || Array.isArray(tokens)) { + console.warn('Gleap: setAuthTokens expects an object of { name: token }.'); + return Promise.resolve(); + } + const change = {}; + Object.keys(tokens).forEach((name) => { + const value = tokens[name]; + if (!AUTH_TOKEN_NAME.test(name)) { + console.warn(`Gleap: auth token name "${name}" skipped: use a letter followed by up to 39 letters, digits, _ or -.`); + return; + } + if (value !== null && typeof value !== 'string') { + console.warn(`Gleap: auth token "${name}" skipped: the value must be a string, or null to remove it.`); + return; + } + change[name] = value; + if (value === null) { + delete this.authTokens[name]; + } else { + this.authTokens[name] = value; + } + }); + if (Object.keys(change).length === 0) { + return Promise.resolve(); + } + return this.postAuthTokens(change); + }; + + resendAuthTokensIfNeeded = () => { + if (Object.keys(this.authTokens).length > 0 && this.authTokensSentFor !== this.session.gleapId) { + this.postAuthTokens({ ...this.authTokens }); + } + }; + + /** POST /sessions/auth-tokens once the session is ready and no identify is in flight. Never rejects. */ + postAuthTokens = (tokens) => { + const self = this; + return new Promise((resolve) => { + this.setOnSessionReady(() => { + this.onContactSettled(() => { + if (!self.session.gleapId || !self.session.gleapHash) { + return resolve(); + } + const gleapId = self.session.gleapId; + const http = new XMLHttpRequest(); + http.open('POST', self.apiUrl + '/sessions/auth-tokens'); + http.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); + self.injectSession(http); + http.onerror = () => resolve(); + http.onreadystatechange = function () { + if (http.readyState !== 4) { + return; + } + if (http.status === 200 || http.status === 201) { + self.authTokensSentFor = gleapId; + } else if (http.status === 401) { + console.warn('Gleap: auth tokens need a signed identity: call Gleap.setUserJwt first. They are kept and sent after the next signed identify.'); + } + resolve(); + }; + http.send(JSON.stringify({ tokens })); + }); + }); + }); }; startProductTourConfig = (tourId) => { @@ -784,6 +1515,7 @@ export default class GleapSession { try { http.setRequestHeader('Gleap-Id', self.session.gleapId); http.setRequestHeader('Gleap-Hash', self.session.gleapHash); + self.injectGrant(http); } catch (exp) {} http.onerror = () => { @@ -829,6 +1561,7 @@ export default class GleapSession { try { http.setRequestHeader('Gleap-Id', self.session.gleapId); http.setRequestHeader('Gleap-Hash', self.session.gleapHash); + self.injectGrant(http); } catch (exp) {} http.onerror = () => { diff --git a/src/GleapSession.test.js b/src/GleapSession.test.js index 853be34b..b3d9907f 100644 --- a/src/GleapSession.test.js +++ b/src/GleapSession.test.js @@ -1,5 +1,5 @@ import GleapSession from './GleapSession'; -import { saveToGleapCache, loadFromGleapCache } from './GleapHelper'; +import { saveToGleapCache, loadFromGleapCache, setGleapCookie, eraseGleapCookie, getGleapCookie } from './GleapHelper'; // The session manager only needs the sibling managers to exist; none of the // paths under test depend on their behavior beyond being callable. @@ -32,6 +32,11 @@ jest.mock('./GleapTooltipManager', () => ({ default: { getInstance: jest.fn(() => ({ destroy: jest.fn(), load: jest.fn() })) }, })); +jest.mock('./GleapAiChatbarManager', () => ({ + __esModule: true, + default: { instance: null }, +})); + jest.mock('./GleapHelper', () => ({ eraseGleapCookie: jest.fn(), getDeviceType: jest.fn(() => 'desktop'), @@ -406,3 +411,662 @@ describe('authenticated file sessions', () => { expect(session.lastIdentify).toBeNull(); }); }); + +describe('identity tokens (setUserJwt)', () => { + const b64url = (value) => Buffer.from(value).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const jwtOf = (payload) => `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(JSON.stringify(payload))}.c2ln`; + const inSeconds = (seconds) => Math.floor(Date.now() / 1000) + seconds; + const flush = async () => { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + }; + const respondIdentified = (xhr) => xhr.respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION })); + const refusal = (code) => JSON.stringify({ status: 401, title: 'Unauthorized', detail: { value: code, message: code } }); + + let warn; + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + warn.mockRestore(); + }); + + const readySession = () => { + seedCachedSession(); + const session = buildSession(); + session.startSession(); + return session; + }; + + it('sends the decoded userId with the token as userHash and no contact fields', () => { + const session = readySession(); + const token = jwtOf({ userId: 'user-1', email: 'ignored@example.com', exp: inSeconds(3600) }); + session.setUserJwt(token); + + expect(identifyRequests()).toHaveLength(1); + const body = JSON.parse(identifyRequests()[0].body); + expect(body.userId).toBe('user-1'); + expect(body.userHash).toBe(token); + expect(body.email).toBeUndefined(); + expect(body.name).toBeUndefined(); + }); + + it('refuses a bad token with a warning and no request', async () => { + const session = readySession(); + await session.setUserJwt('not-a-jwt'); + await session.setUserJwt(jwtOf({ email: 'no-user@example.com' })); + expect(identifyRequests()).toHaveLength(0); + expect(warn).toHaveBeenCalledTimes(2); + }); + + it('sends an unchanged token once, a re-minted one not again, and a token with changed claims once more', () => { + const session = readySession(); + const first = jwtOf({ userId: 'user-1', plan: 'pro', iat: inSeconds(0), exp: inSeconds(3600) }); + session.setUserJwt(first); + respondIdentified(identifyRequests()[0]); + session.setUserJwt(first); + expect(identifyRequests()).toHaveLength(1); + + // A backend that mints a fresh token per page load: same user and claims, new iat/exp/jti. + session.setUserJwt(jwtOf({ userId: 'user-1', plan: 'pro', iat: inSeconds(60), exp: inSeconds(7200), jti: 'n2' })); + expect(identifyRequests()).toHaveLength(1); + + const changed = jwtOf({ userId: 'user-1', plan: 'enterprise', exp: inSeconds(7200) }); + session.setUserJwt(changed); + expect(identifyRequests()).toHaveLength(2); + expect(JSON.parse(identifyRequests()[1].body).userHash).toBe(changed); + respondIdentified(identifyRequests()[1]); + session.setUserJwt(changed); + expect(identifyRequests()).toHaveLength(2); + }); + + it('logs signature_required and resolves', async () => { + const session = readySession(); + const done = session.setUserJwt(jwtOf({ userId: 'user-1' })); + identifyRequests()[0].respond(401, refusal('signature_required')); + await expect(done).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + 'Gleap: this project enforces identity verification and the identify had no valid signature (signature_required). Pass a token from your backend to setUserJwt.' + ); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('never rejects on a network error or a server error, and logs once', async () => { + const session = readySession(); + const offline = session.setUserJwt(jwtOf({ userId: 'user-1' })); + identifyRequests()[0].error(); + await expect(offline).resolves.toBeUndefined(); + expect(warn).toHaveBeenLastCalledWith(expect.stringContaining('network error')); + + const unavailable = session.setUserJwt(jwtOf({ userId: 'user-1', plan: 'x' })); + identifyRequests()[1].respond(503, ''); + await expect(unavailable).resolves.toBeUndefined(); + expect(warn).toHaveBeenLastCalledWith(expect.stringContaining('HTTP 503')); + expect(warn).toHaveBeenCalledTimes(2); + }); + + it('keeps the legacy hash identify keyed by user only', () => { + const session = readySession(); + session.identifySession('user-1', { email: 'bec@example.com' }, 'verified-hmac'); + respondIdentified(identifyRequests()[0]); + expect(cacheStore['signed-identify-sdk-key']).toBe('gleap-id-1:user-1'); + }); + + it('on token_expired asks the provider once and retries with the new token', async () => { + const session = readySession(); + const fresh = jwtOf({ userId: 'user-1', exp: inSeconds(3600) }); + const provider = jest.fn(() => Promise.resolve(fresh)); + session.setUserJwtProvider(provider); + + const done = session.setUserJwt(jwtOf({ userId: 'user-1', exp: inSeconds(-10) })); + identifyRequests()[0].respond(401, refusal('token_expired')); + await flush(); + + expect(provider).toHaveBeenCalledTimes(1); + expect(identifyRequests()).toHaveLength(2); + expect(JSON.parse(identifyRequests()[1].body).userHash).toBe(fresh); + respondIdentified(identifyRequests()[1]); + await expect(done).resolves.toBeUndefined(); + expect(session.lastIdentify.userHash).toBe(fresh); + }); + + it('retries only once and shares one provider call', async () => { + const session = readySession(); + const provider = jest.fn(() => Promise.resolve(jwtOf({ userId: 'user-1', exp: inSeconds(3600) }))); + session.setUserJwtProvider(provider); + + const done = session.setUserJwt(jwtOf({ userId: 'user-1', exp: inSeconds(-10) })); + const parallel = [session.requestUserJwt(), session.requestUserJwt()]; + identifyRequests()[0].respond(401, refusal('token_expired')); + await flush(); + await Promise.all(parallel); + expect(provider).toHaveBeenCalledTimes(1); + + identifyRequests()[1].respond(401, refusal('token_expired')); + await expect(done).resolves.toBeUndefined(); + expect(identifyRequests()).toHaveLength(2); + }); + + it('pauses 30 s after a provider failure', async () => { + const session = buildSession(); + const provider = jest.fn(() => Promise.reject(new Error('offline'))); + session.setUserJwtProvider(provider); + + expect(await session.requestUserJwt()).toBeNull(); + expect(await session.requestUserJwt()).toBeNull(); + expect(provider).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(30 * 1000); + await session.requestUserJwt(); + expect(provider).toHaveBeenCalledTimes(2); + }); + + it('gives up on a provider after 10 s', async () => { + const session = buildSession(); + session.setUserJwtProvider(() => new Promise(() => {})); + const pending = session.requestUserJwt(); + jest.advanceTimersByTime(10 * 1000); + expect(await pending).toBeNull(); + }); + + it.each(['token_expired', 'token_invalid'])('keeps the local session on %s', async (code) => { + const session = readySession(); + const done = session.setUserJwt(jwtOf({ userId: 'user-1' })); + identifyRequests()[0].respond(401, refusal(code)); + await expect(done).resolves.toBeUndefined(); + + expect(session.session.gleapId).toBe('gleap-id-1'); + expect(session.ready).toBe(true); + expect(saveToGleapCache).not.toHaveBeenCalledWith('session-sdk-key', null); + expect(warn).toHaveBeenCalled(); + }); + + it('asks the provider before a file-access renewal when the token expires within 5 minutes', async () => { + const session = buildSession(); + const fresh = jwtOf({ userId: 'user-1', exp: inSeconds(3600) }); + const provider = jest.fn(() => fresh); + session.setUserJwtProvider(provider); + session.lastIdentify = { userId: 'user-1', userData: {}, userHash: jwtOf({ userId: 'user-1', exp: inSeconds(60) }) }; + session.validateSession({ ...CACHED_IDENTIFIED_SESSION, fileAccessToken: 'file-session', fileAccessExpiresAt: new Date(Date.now() + 900000).toISOString() }); + + jest.advanceTimersByTime(600000); + await flush(); + expect(provider).toHaveBeenCalledTimes(1); + expect(identifyRequests()).toHaveLength(1); + expect(JSON.parse(identifyRequests()[0].body).userHash).toBe(fresh); + }); + + it('clearIdentity drops the token and auth tokens but keeps the provider', () => { + const session = readySession(); + const provider = jest.fn(); + session.setUserJwtProvider(provider); + session.setUserJwt(jwtOf({ userId: 'user-1' })); + session.authTokens = { backend: 'secret' }; + session.clearSession(0, false); + expect(session.lastIdentify).toBeNull(); + expect(session.authTokens).toEqual({}); + expect(session.jwtProvider).toBe(provider); + }); +}); + +describe('auth tokens (setAuthTokens)', () => { + const authTokenRequests = () => MockXhr.instances.filter((xhr) => xhr.url && xhr.url.endsWith('/sessions/auth-tokens')); + let warn; + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + warn.mockRestore(); + }); + + const readySession = () => { + seedCachedSession(); + const session = buildSession(); + session.startSession(); + return session; + }; + + it('skips invalid names with a warning and sends the rest with the session headers', () => { + const session = readySession(); + session.setAuthTokens({ backend: 'a', '1bad': 'b', 'has space': 'c', ['x'.repeat(41)]: 'd', crm_v2: 'e', number: 5 }); + + expect(warn).toHaveBeenCalledTimes(4); + expect(authTokenRequests()).toHaveLength(1); + const request = authTokenRequests()[0]; + expect(JSON.parse(request.body)).toEqual({ tokens: { backend: 'a', crm_v2: 'e' } }); + expect(request.headers['Gleap-Id']).toBe('gleap-id-1'); + expect(request.headers['Gleap-Hash']).toBe('gleap-hash-1'); + expect(request.headers['Api-Token']).toBe('sdk-key'); + }); + + it('sends nothing when every name is invalid', () => { + const session = readySession(); + session.setAuthTokens({ '-x': 'a' }); + expect(authTokenRequests()).toHaveLength(0); + }); + + it('a null value deletes the token from memory and is sent as null', () => { + const session = readySession(); + session.setAuthTokens({ backend: 'a', crm: 'b' }); + session.setAuthTokens({ crm: null }); + expect(session.authTokens).toEqual({ backend: 'a' }); + expect(JSON.parse(authTokenRequests()[1].body)).toEqual({ tokens: { crm: null } }); + }); + + it('warns on 401 and keeps the session', async () => { + const session = readySession(); + const done = session.setAuthTokens({ backend: 'a' }); + authTokenRequests()[0].respond(401, '{"detail":{"value":"signature_required"}}'); + await done; + expect(warn).toHaveBeenCalledTimes(1); + expect(session.session.gleapId).toBe('gleap-id-1'); + }); + + it('resends the tokens after a signed identify on a session they were not sent to', async () => { + const session = readySession(); + session.setAuthTokens({ backend: 'a' }); + authTokenRequests()[0].respond(401, '{}'); + await Promise.resolve(); + + session.identifySession('user-9', {}, 'verified-hmac'); + identifyRequests()[0].respond(200, JSON.stringify({ gleapId: 'gleap-id-9', gleapHash: 'gleap-hash-9', userId: 'user-9' })); + await Promise.resolve(); + await Promise.resolve(); + + expect(authTokenRequests()).toHaveLength(2); + expect(authTokenRequests()[1].headers['Gleap-Id']).toBe('gleap-id-9'); + expect(JSON.parse(authTokenRequests()[1].body)).toEqual({ tokens: { backend: 'a' } }); + }); +}); + +describe('grant (signed session credential)', () => { + const b64url = (value) => Buffer.from(value).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + const jwtOf = (payload) => `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(JSON.stringify(payload))}.c2ln`; + const inSeconds = (seconds) => Math.floor(Date.now() / 1000) + seconds; + const flush = async () => { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + }; + const grantFor = (sid, exp = inSeconds(3600)) => jwtOf({ sid, uid: 'user-1', kid: 'k1', kind: 'jwt', iat: inSeconds(0), exp }); + const respondWithGrant = (xhr, grant, extra = {}) => + xhr.respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, ...extra, identity: { signed: true, kid: 'k1', kind: 'jwt', grant } })); + + let warn; + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + setGleapCookie.mockClear(); + eraseGleapCookie.mockClear(); + getGleapCookie.mockReset().mockImplementation(() => null); + }); + afterEach(() => { + warn.mockRestore(); + }); + + const readySession = () => { + seedCachedSession(); + const session = buildSession(); + session.startSession(); + return session; + }; + + it('keeps the grant out of the persisted session and sends it on later requests', () => { + const session = readySession(); + const grant = grantFor('gleap-id-1'); + session.setUserJwt(jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); + respondWithGrant(identifyRequests()[0], grant); + + expect(session.grant).toBe(grant); + expect(JSON.stringify(session.session)).not.toContain(grant); + expect(JSON.stringify(cacheStore['session-sdk-key'])).not.toContain(grant); + const sessionCookieWrites = setGleapCookie.mock.calls.filter(([name]) => name === 'session-sdk-key'); + expect(sessionCookieWrites.every(([, value]) => !decodeURIComponent(value).includes(grant))).toBe(true); + expect(setGleapCookie).toHaveBeenCalledWith('gleap-grant-sdk-key', encodeURIComponent(grant), expect.any(Number), true); + + session.updateSession({ name: 'Other' }); + const update = MockXhr.instances.find((xhr) => xhr.url.endsWith('/sessions/partialupdate')); + expect(update.headers['Gleap-Grant']).toBe(grant); + const xhr = new MockXhr(); + session.injectSession(xhr); + expect(xhr.headers['Gleap-Grant']).toBe(grant); + }); + + it('restores the grant from its cookie only for the same session', () => { + getGleapCookie.mockImplementation((name) => (name === 'gleap-grant-sdk-key' ? encodeURIComponent(grantFor('gleap-id-1')) : null)); + const session = readySession(); + expect(session.grant).toBe(grantFor('gleap-id-1')); + expect(MockXhr.instances[0].headers['Gleap-Grant']).toBe(grantFor('gleap-id-1')); + + getGleapCookie.mockImplementation((name) => (name === 'gleap-grant-sdk-key' ? encodeURIComponent(grantFor('someone-else')) : null)); + const other = readySession(); + expect(other.grant).toBe(null); + }); + + it('drops the grant when the session changes to another gleapId', () => { + const session = readySession(); + session.setUserJwt(jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); + respondWithGrant(identifyRequests()[0], grantFor('gleap-id-1')); + session.validateSession({ gleapId: 'gleap-id-2', gleapHash: 'gleap-hash-2' }); + expect(session.grant).toBe(null); + }); + + it('clearSession drops the grant and erases its cookie', () => { + const session = readySession(); + session.setUserJwt(jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); + respondWithGrant(identifyRequests()[0], grantFor('gleap-id-1')); + const provider = jest.fn(); + session.setUserJwtProvider(provider); + session.clearSession(0, false); + expect(session.grant).toBe(null); + expect(eraseGleapCookie).toHaveBeenCalledWith('gleap-grant-sdk-key'); + expect(session.jwtProvider).toBe(provider); + }); + + it('identifies again when the stored grant is gone, not when it is live', () => { + const session = readySession(); + const token = jwtOf({ userId: 'user-1', exp: inSeconds(3600) }); + session.setUserJwt(token); + respondWithGrant(identifyRequests()[0], grantFor('gleap-id-1')); + session.setUserJwt(token); + expect(identifyRequests()).toHaveLength(1); + + session.setGrant(null, false); + session.setUserJwt(token); + expect(identifyRequests()).toHaveLength(2); + }); + + it('identity_mismatch: drops the session and retries once as a fresh visitor', async () => { + const session = readySession(); + session.setAuthTokens({ backend: 'abc' }); + const token = jwtOf({ userId: 'user-2', exp: inSeconds(3600) }); + session.setUserJwt(token).catch(() => {}); + identifyRequests()[0].respond(400, JSON.stringify({ error: { details: { value: 'identity_mismatch' } } })); + await flush(); + + expect(session.session.gleapId).toBe(null); + const sessionsRequests = MockXhr.instances.filter((xhr) => xhr.url.endsWith('/sessions')); + 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' })); + + expect(identifyRequests()).toHaveLength(2); + expect(identifyRequests()[1].headers['Gleap-Id']).toBe('gleap-id-new'); + expect(JSON.parse(identifyRequests()[1].body).userHash).toBe(token); + expect(session.authTokens).toEqual({ backend: 'abc' }); + + identifyRequests()[1].respond(400, JSON.stringify({ error: { details: { value: 'identity_mismatch' } } })); + await flush(); + expect(identifyRequests()).toHaveLength(2); + expect(session.session.gleapId).toBe('gleap-id-new'); + }); + + describe('refresh', () => { + const shortGrant = (lifetimeSeconds, iatOffset = 0) => + jwtOf({ sid: 'gleap-id-1', uid: 'user-1', kid: 'k1', kind: 'jwt', iat: inSeconds(iatOffset), exp: inSeconds(iatOffset + lifetimeSeconds) }); + const signIn = (session, grant) => { + session.setUserJwt(jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); + respondWithGrant(identifyRequests()[identifyRequests().length - 1], grant); + }; + + it('renews a minute before the grant lifetime ends, counted from receipt', async () => { + const session = readySession(); + signIn(session, shortGrant(3600)); + jest.advanceTimersByTime(3540 * 1000 - 1); + expect(identifyRequests()).toHaveLength(1); + jest.advanceTimersByTime(1); + await flush(); + expect(identifyRequests()).toHaveLength(2); + }); + + it('a 60-70 s grant waits 30 s and renews at most once a minute (no loop)', async () => { + const session = readySession(); + signIn(session, shortGrant(65)); + jest.advanceTimersByTime(29 * 1000); + expect(identifyRequests()).toHaveLength(1); + jest.advanceTimersByTime(1000); + await flush(); + expect(identifyRequests()).toHaveLength(2); + + // The refresh answers with another short grant: the next refresh waits for the minute. + jest.advanceTimersByTime(1000); + respondWithGrant(identifyRequests()[1], shortGrant(65, 1)); + await flush(); + jest.advanceTimersByTime(30 * 1000); + await flush(); + expect(identifyRequests()).toHaveLength(2); + jest.advanceTimersByTime(29 * 1000); + await flush(); + expect(identifyRequests()).toHaveLength(3); + }); + + it('a fast device clock does not make a fresh grant due at once', async () => { + const session = readySession(); + // Issued by a server whose clock is two hours behind the device: exp already lies in the device's past. + signIn(session, shortGrant(3600, -7200)); + jest.advanceTimersByTime(60 * 1000); + await flush(); + expect(identifyRequests()).toHaveLength(1); + jest.advanceTimersByTime(3480 * 1000); + await flush(); + expect(identifyRequests()).toHaveLength(2); + }); + + it('skips while a refresh is in flight', async () => { + const session = readySession(); + signIn(session, shortGrant(65)); + jest.advanceTimersByTime(30 * 1000); + await flush(); + expect(identifyRequests()).toHaveLength(2); + expect(session.grantRefreshInFlight).toBe(true); + // Re-arming for the same grant while the refresh runs sends nothing more. + session.armGrantRefresh(session.grant, Date.now()); + jest.advanceTimersByTime(120 * 1000); + await flush(); + expect(identifyRequests()).toHaveLength(2); + identifyRequests()[1].error(); + await flush(); + expect(session.grantRefreshInFlight).toBe(false); + }); + + it('a failed refresh is tried again a minute later', async () => { + const session = readySession(); + signIn(session, shortGrant(65)); + jest.advanceTimersByTime(30 * 1000); + await flush(); + expect(identifyRequests()).toHaveLength(2); + identifyRequests()[1].error(); + await flush(); + jest.advanceTimersByTime(59 * 1000); + await flush(); + expect(identifyRequests()).toHaveLength(2); + jest.advanceTimersByTime(1000); + await flush(); + expect(identifyRequests()).toHaveLength(3); + }); + }); + + describe('signed mode without a grant (returning users)', () => { + const SIGNED_MODE = { identity: { signed: false, grant: null, mode: 'signed' } }; + const sessionsRequests = () => MockXhr.instances.filter((xhr) => xhr.url.endsWith('/sessions')); + const token = () => jwtOf({ userId: 'user-1', exp: inSeconds(3600) }); + + // A user identified while the project was Classic: the identify fingerprint is stored, no grant. + const identifiedUnderClassic = () => { + const first = readySession(); + first.setUserJwt(token()); + identifyRequests()[0].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION })); + expect(cacheStore['signed-identify-sdk-key']).toBeTruthy(); + MockXhr.instances = []; + const next = buildSession(); + next.startSession(); + return next; + }; + + it('sends one identify when /sessions says signed mode after the identify was skipped', () => { + const session = identifiedUnderClassic(); + session.setUserJwt(token()); + expect(identifyRequests()).toHaveLength(0); + + sessionsRequests()[0].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, ...SIGNED_MODE })); + expect(identifyRequests()).toHaveLength(1); + expect(JSON.parse(identifyRequests()[0].body).userHash).toBe(token()); + + // Still no grant: no loop, neither from the answer nor from later renders or /sessions answers. + identifyRequests()[0].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, ...SIGNED_MODE })); + session.setUserJwt(token()); + session.startSession(); + sessionsRequests()[1].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, ...SIGNED_MODE })); + jest.advanceTimersByTime(10 * 60 * 1000); + expect(identifyRequests()).toHaveLength(1); + }); + + it('sends one identify when the app identifies after the signed-mode /sessions answer', () => { + const session = identifiedUnderClassic(); + sessionsRequests()[0].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, ...SIGNED_MODE })); + expect(identifyRequests()).toHaveLength(0); + session.setUserJwt(token()); + session.setUserJwt(token()); + expect(identifyRequests()).toHaveLength(1); + }); + + it('drops a grant the server rejected and re-identifies once', () => { + const session = readySession(); + const jwt = token(); + session.setUserJwt(jwt); + const grant = grantFor('gleap-id-1'); + respondWithGrant(identifyRequests()[0], grant); + expect(session.hasLiveGrant()).toBe(true); + eraseGleapCookie.mockClear(); + MockXhr.instances = []; + jest.advanceTimersByTime(60 * 1000); + + // The grant still looks live locally, but the server no longer accepts it. + session.startSession(); + expect(sessionsRequests()[0].headers['Gleap-Grant']).toBe(grant); + // Not signed, with the grant left out of the answer. + sessionsRequests()[0].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, identity: { signed: false, mode: 'signed' } })); + expect(session.grant).toBe(null); + expect(eraseGleapCookie).toHaveBeenCalledWith('gleap-grant-sdk-key'); + expect(identifyRequests()).toHaveLength(1); + expect(identifyRequests()[0].headers['Gleap-Grant']).toBeUndefined(); + expect(JSON.parse(identifyRequests()[0].body).userHash).toBe(jwt); + + // Still not signed: no second identify this page load. + identifyRequests()[0].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, identity: { signed: false, mode: 'signed' } })); + session.startSession(); + sessionsRequests()[1].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, identity: { signed: false, mode: 'signed' } })); + jest.advanceTimersByTime(10 * 60 * 1000); + expect(identifyRequests()).toHaveLength(1); + }); + + it('sends nothing for a Classic answer or with a live grant', () => { + const session = identifiedUnderClassic(); + session.setUserJwt(token()); + sessionsRequests()[0].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION })); + expect(identifyRequests()).toHaveLength(0); + + session.grant = grantFor('gleap-id-1'); + session.startSession(); + sessionsRequests()[1].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, identity: { signed: true, mode: 'signed' } })); + expect(identifyRequests()).toHaveLength(0); + }); + }); + + it('drops an identify answer that lands after a logout', async () => { + const session = readySession(); + session.setUserJwt(jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); + session.clearSession(0, false); + respondWithGrant(identifyRequests()[0], grantFor('gleap-id-1')); + await flush(); + + expect(session.session.gleapId).toBe(null); + expect(session.session.userId).toBe(''); + expect(session.grant).toBe(null); + expect(cacheStore['session-sdk-key']).toBe(null); + }); + + it('signature_required for another user clears the previous user\'s session', () => { + const session = readySession(); + session.identifySession('user-1', { name: 'Changed' }, 'hash-1')?.catch(() => {}); + identifyRequests()[0].respond(401, JSON.stringify({ detail: { value: 'signature_required' } })); + expect(session.session.gleapId).toBe('gleap-id-1'); + + session.identifySession('user-2', {}, 'hash-2')?.catch(() => {}); + identifyRequests()[1].respond(401, JSON.stringify({ detail: { value: 'signature_required' } })); + expect(session.session.gleapId).toBe(null); + expect(session.session.userId).toBe(''); + expect(cacheStore['session-sdk-key']).toBe(null); + }); + + it('does not resend a refused token on every render, but a new one', () => { + const session = readySession(); + const refused = jwtOf({ userId: 'user-1', plan: 'x', exp: inSeconds(3600) }); + session.setUserJwt(refused); + identifyRequests()[0].respond(401, JSON.stringify({ detail: { value: 'token_invalid' } })); + session.setUserJwt(refused); + session.setUserJwt(refused); + expect(identifyRequests()).toHaveLength(1); + + session.setUserJwt(jwtOf({ userId: 'user-1', plan: 'x', exp: inSeconds(3601) })); + expect(identifyRequests()).toHaveLength(2); + }); + + it('a provider token that already expired counts as a failure (30 s pause)', async () => { + const session = readySession(); + const provider = jest.fn(() => jwtOf({ userId: 'user-1', exp: inSeconds(0) })); + session.setUserJwtProvider(provider); + await expect(session.requestUserJwt()).resolves.toBe(null); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('expired token')); + await expect(session.requestUserJwt()).resolves.toBe(null); + expect(provider).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(30 * 1000); + await session.requestUserJwt(); + expect(provider).toHaveBeenCalledTimes(2); + }); + + it('replays an identify that failed on the network once the browser is back online', async () => { + const session = readySession(); + expect(window.addEventListener).toHaveBeenCalledWith('online', session.handleOnline); + session.setUserJwt(jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); + identifyRequests()[0].error(); + await flush(); + + session.handleOnline(); + expect(identifyRequests()).toHaveLength(2); + respondWithGrant(identifyRequests()[1], grantFor('gleap-id-1')); + session.handleOnline(); + expect(identifyRequests()).toHaveLength(2); + expect(session.grant).toBe(grantFor('gleap-id-1')); + }); + + it('setUserJwt before initialize: no identify when the cached grant is live', () => { + const first = readySession(); + const token = jwtOf({ userId: 'user-1', exp: inSeconds(3600) }); + first.setUserJwt(token); + respondWithGrant(identifyRequests()[0], grantFor('gleap-id-1')); + MockXhr.instances = []; + + getGleapCookie.mockImplementation((name) => (name === 'gleap-grant-sdk-key' ? encodeURIComponent(grantFor('gleap-id-1')) : null)); + const next = new GleapSession(); + next.setUserJwt(token); + next.sdkKey = 'sdk-key'; + next.startSession(); + expect(identifyRequests()).toHaveLength(0); + expect(next.grant).toBe(grantFor('gleap-id-1')); + }); + + it('turning cookies off erases the grant cookie', () => { + const session = readySession(); + eraseGleapCookie.mockClear(); + session.setUseCookies(false); + expect(eraseGleapCookie).toHaveBeenCalledWith('gleap-grant-sdk-key'); + expect(session.useCookies).toBe(false); + }); + + it('sends sessionDuration with identify', () => { + const session = readySession(); + session.setSessionDuration(60 * 60 * 1000); + session.setUserJwt(jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); + expect(JSON.parse(identifyRequests()[0].body).sessionDuration).toBe(3600000); + }); +}); diff --git a/src/GleapTooltipManager.js b/src/GleapTooltipManager.js index 18386548..14df2f99 100644 --- a/src/GleapTooltipManager.js +++ b/src/GleapTooltipManager.js @@ -729,6 +729,7 @@ export default class GleapTooltipManager { try { http.setRequestHeader('Gleap-Id', sessionInstance.session.gleapId); http.setRequestHeader('Gleap-Hash', sessionInstance.session.gleapHash); + sessionInstance.injectGrant(http); } catch (exp) {} http.onerror = () => { diff --git a/src/GleapUserJwt.js b/src/GleapUserJwt.js new file mode 100644 index 00000000..0881c5f1 --- /dev/null +++ b/src/GleapUserJwt.js @@ -0,0 +1,141 @@ +// Identity tokens (Gleap.setUserJwt): HS256 JWTs signed by the app's backend. +// The SDK only reads the payload; the server checks the signature. + +export const MAX_USER_JWT_LENGTH = 4096; + +const SEGMENT = /^[A-Za-z0-9_-]+$/; + +const decodeSegment = (segment) => { + const base64 = segment.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4)); + // UTF-8 bytes to a JS string (names with umlauts, emoji, ...). + return decodeURIComponent( + binary + .split('') + .map((char) => '%' + ('0' + char.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ); +}; + +/** True for a string shaped like a JWT (three non-empty base64url segments, at most 4096 chars). */ +export const isUserJwt = (token) => { + if (typeof token !== 'string' || token.length > MAX_USER_JWT_LENGTH) { + return false; + } + const segments = token.split('.'); + return segments.length === 3 && segments.every((segment) => SEGMENT.test(segment)); +}; + +/** + * Reads `{ userId, exp }` from a JWT's payload without verifying it. + * `userId` (or `user_id`) must be a non-empty string or a finite number; it comes back as a string. + * Returns null for anything else. + */ +export const decodeUserJwt = (token) => { + if (!isUserJwt(token)) { + return null; + } + try { + const payload = JSON.parse(decodeSegment(token.split('.')[1])); + if (!payload || typeof payload !== 'object') { + return null; + } + const userId = payload.userId ?? payload.user_id; + const validUserId = (typeof userId === 'string' && userId.length > 0) || (typeof userId === 'number' && isFinite(userId)); + if (!validUserId) { + return null; + } + return { userId: String(userId), exp: typeof payload.exp === 'number' ? payload.exp : null }; + } catch (exp) { + return null; + } +}; + +/** + * Reads `{ sid, exp }` from a grant (the server-signed session credential an + * identify returns) without verifying it. Null for anything that is not a JWT. + */ +export const decodeGrant = (grant) => { + if (!isUserJwt(grant)) { + return null; + } + try { + const payload = JSON.parse(decodeSegment(grant.split('.')[1])); + if (!payload || typeof payload !== 'object') { + return null; + } + return { + sid: typeof payload.sid === 'string' ? payload.sid : null, + iat: typeof payload.iat === 'number' ? payload.iat : null, + exp: typeof payload.exp === 'number' ? payload.exp : null, + }; + } catch (exp) { + return null; + } +}; + +/** A short non-reversible fingerprint (djb2), so "this token was sent" can be remembered without storing it. */ +export const fingerprintOf = (value) => { + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = ((hash << 5) + hash + value.charCodeAt(i)) | 0; + } + return (hash >>> 0).toString(36); +}; + +// Claims that change on every mint without changing who the token names or what it says. +const VOLATILE_CLAIMS = ['iat', 'exp', 'nbf', 'jti']; + +const stableStringify = (value) => { + if (Array.isArray(value)) { + return '[' + value.map(stableStringify).join(',') + ']'; + } + if (value && typeof value === 'object') { + return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + stableStringify(value[key])).join(',') + '}'; + } + return JSON.stringify(value); +}; + +/** + * A fingerprint of what an identity token says: its `kid` and its claims + * without iat/exp/nbf/jti. A backend that mints a fresh token per page load + * for the same user and data gets the same fingerprint, so the identify is not + * sent again; a changed claim (email, plan, ...) or key does change it. + * Null for anything that is not a JWT. + */ +export const identityFingerprintOf = (token) => { + if (!isUserJwt(token)) { + return null; + } + try { + const [header, payload] = token.split('.').slice(0, 2).map((segment) => JSON.parse(decodeSegment(segment))); + const claims = { ...(payload && typeof payload === 'object' ? payload : {}) }; + VOLATILE_CLAIMS.forEach((claim) => delete claims[claim]); + return fingerprintOf(stableStringify({ kid: header && header.kid !== undefined ? header.kid : null, claims })); + } catch (exp) { + return fingerprintOf(token); + } +}; + +/** The identify refusal code in a 401 body (contract §3: token_expired, token_invalid, signature_required) or a 400 `identity_mismatch`, or null. */ +export const identifyRefusalOf = (status, responseText) => { + if (typeof responseText !== 'string') { + return null; + } + if (status === 400) { + return responseText.indexOf('identity_mismatch') !== -1 ? 'identity_mismatch' : null; + } + if (status !== 401) { + return null; + } + if (responseText.indexOf('token_expired') !== -1) { + return 'token_expired'; + } + if (responseText.indexOf('token_invalid') !== -1) { + return 'token_invalid'; + } + if (responseText.indexOf('signature_required') !== -1) { + return 'signature_required'; + } + return null; +}; diff --git a/src/GleapUserJwt.test.js b/src/GleapUserJwt.test.js new file mode 100644 index 00000000..21b01e47 --- /dev/null +++ b/src/GleapUserJwt.test.js @@ -0,0 +1,51 @@ +import { decodeUserJwt, identifyRefusalOf } from './GleapUserJwt'; + +const b64url = (value) => Buffer.from(value).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +const jwtOf = (payload) => `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(JSON.stringify(payload))}.c2lnbmF0dXJl`; + +describe('decodeUserJwt', () => { + it('reads userId and exp', () => { + expect(decodeUserJwt(jwtOf({ userId: 'user-1', email: 'a@b.c', exp: 1700000000 }))).toEqual({ userId: 'user-1', exp: 1700000000 }); + }); + + it('accepts user_id and numbers, returning a string', () => { + expect(decodeUserJwt(jwtOf({ user_id: 42 }))).toEqual({ userId: '42', exp: null }); + expect(decodeUserJwt(jwtOf({ userId: 7, user_id: 'ignored' })).userId).toBe('7'); + }); + + it('decodes UTF-8 payloads', () => { + expect(decodeUserJwt(jwtOf({ userId: 'jürgen-😀' })).userId).toBe('jürgen-😀'); + }); + + it.each([ + ['not a string', 123], + ['two segments', 'abc.def'], + ['four segments', 'a.b.c.d'], + ['an empty segment', `${b64url('{}')}..sig`], + ['non-base64url characters', 'a+b.c/d.e'], + ['a payload that is not JSON', `a.${b64url('nope')}.c`], + ['no userId', jwtOf({ email: 'a@b.c' })], + ['an empty userId', jwtOf({ userId: '' })], + ['an object userId', jwtOf({ userId: { $ne: null } })], + ])('refuses %s', (_, token) => { + expect(decodeUserJwt(token)).toBeNull(); + }); + + it('refuses tokens over 4096 characters', () => { + const big = jwtOf({ userId: 'user-1', pad: 'x'.repeat(3100) }); + expect(big.length).toBeGreaterThan(4096); + expect(decodeUserJwt(big)).toBeNull(); + }); +}); + +describe('identifyRefusalOf', () => { + it('finds the code in a 401 body only', () => { + const body = JSON.stringify({ status: 401, detail: { value: 'token_expired' } }); + expect(identifyRefusalOf(401, body)).toBe('token_expired'); + expect(identifyRefusalOf(401, '{"details":{"value":"token_invalid"}}')).toBe('token_invalid'); + expect(identifyRefusalOf(401, '{"details":{"value":"signature_required"}}')).toBe('signature_required'); + expect(identifyRefusalOf(403, body)).toBeNull(); + expect(identifyRefusalOf(400, '{"error":{"details":{"value":"identity_mismatch"}}}')).toBe('identity_mismatch'); + expect(identifyRefusalOf(400, '{"error":"bad request"}')).toBeNull(); + }); +});