diff --git a/CHANGELOG.md b/CHANGELOG.md index 513d9067..f1cb3342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 19.7.0 +Changed: a signed session that expires now ends, as in Intercom's web Messenger. When the grant runs out (the project's "Signed login lasts", 7 days by default, or `sessionDuration`) and no fresh identity token arrives (no `setUserJwt` on that page load, and the JWT provider returns `null` or is not set), the SDK ends the identified session and continues as a fresh visitor with no conversation history, like `Gleap.clearIdentity()` (the JWT provider stays set). Until now the user stayed identified but unsigned (14 days of history, protected fields read-only). Before ending it, the SDK tries once to sign the session again: an identify already on its way, the token passed to `setUserJwt`, or the JWT provider. A network failure keeps the session. A later `setUserJwt` (or provider token) signs the user in again, and the server merges their visitor activity on this device into their contact. +Added: a page left open past the expiry is checked when the grant runs out. If another tab renewed the grant, the SDK takes it from the cookie. If the messenger is open, the session ends once it is closed, at the latest an hour after the expiry (Intercom's one-hour cookie for activity after expiry). +Unchanged: users identified with `Gleap.identify(userId, data)` and no token or hash were never signed and stay identified. A grant that is only missing before its expiry (cookie deleted, cookies off) is restored as before. Classic projects are not affected. +The SDK keeps `{ gleapId, userId, exp }` of the last grant (no credential) in the `signed-session-` cache entry and cookie, so a reload after the expiry knows the session was signed. + ## 19.6.1 Documentation only: README, package description and doc comments name the Project ID (Settings → Project → General) and sign users in with `setUserJwt`. No code changes. diff --git a/README.md b/README.md index 5a3ff306..739c68ef 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ Gleap.initialize("YOUR_PROJECT_ID", { sessionDuration: 24 * 60 * 60 * 1000 }); or later with `Gleap.setSessionDuration(24 * 60 * 60 * 1000)` (the same call as in the mobile SDKs). +When the grant runs out and no fresh token arrives (no `setUserJwt` on this page load, and the JWT provider returns `null` or is not set), the signed session ends: the user continues as a visitor with no conversation history, as in Intercom's web Messenger. A token passed later signs them in again, and what they did as a visitor on this device is merged into their contact. If the messenger is open when the grant runs out, the session ends once it is closed, at the latest an hour later. A user identified with `Gleap.identify(userId, data)` and no token or hash was never signed and stays identified (only their last 14 days of conversations are shown in Signed mode). + `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). diff --git a/index.d.ts b/index.d.ts index 24c1f354..9fdc1e28 100644 --- a/index.d.ts +++ b/index.d.ts @@ -24,6 +24,7 @@ export namespace Gleap { * * `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 signed session that runs out without a fresh token continues as a visitor. * 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). * @@ -227,7 +228,8 @@ export namespace Gleap { * a page reload: when the server reports Signed mode for an identified session without * a valid grant (no grant cookie) and no token was passed to `setUserJwt`, it is asked * once per page load and the user is identified with its token. Returning `null` - * sends nothing. One call at a time, 10 s timeout, 30 s pause after a failure. + * sends nothing; when the session's grant has expired, the user then continues as a + * visitor. One call at a time, 10 s timeout, 30 s pause after a failure. * `null` removes the provider. */ function setUserJwtProvider(provider: (() => string | null | Promise) | null): void; diff --git a/package-lock.json b/package-lock.json index 27eaf6cc..176c8c7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gleap", - "version": "19.6.1", + "version": "19.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gleap", - "version": "19.6.1", + "version": "19.7.0", "license": "Commercial", "dependencies": { "@floating-ui/dom": "^1.7.5", diff --git a/package.json b/package.json index 85f2f296..8224d276 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.6.1", + "version": "19.7.0", "main": "build/cjs/index.js", "module": "build/esm/index.mjs", "exports": { diff --git a/src/GleapSession.js b/src/GleapSession.js index 0fca2664..03c9b536 100644 --- a/src/GleapSession.js +++ b/src/GleapSession.js @@ -35,6 +35,11 @@ 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; +// A signed session whose grant runs out while the messenger is open ends +// once it closes, at the latest this long after the grant expired (Intercom's +// one-hour cookie for activity after expiry). Checked once a minute. +const SIGNED_SESSION_GRACE_MS = 60 * 60 * 1000; +const SIGNED_SESSION_GRACE_CHECK_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.'; @@ -137,6 +142,16 @@ export default class GleapSession { // only, never the token itself): an app that identifies on every render // does not send the same refused token again and again. refusedIdentity = null; + // `{ sid, uid, exp }` of the last grant this device received (no credential): + // the session was signed until `exp`. Outlives the grant and its cookie + // (cache plus a cookie, like the session) so a reload after the expiry knows + // the session was signed. Signed mode ends such a session when its grant ran + // out and no fresh token arrives: the user continues as a visitor (Intercom's + // logged-out visitor). Sessions identified without a token or hash never + // get one and stay identified. + signedSession = null; + signedSessionExpiryTimeout = null; + signedSessionExpiryRunning = false; // GleapSession singleton static instance; @@ -317,6 +332,9 @@ export default class GleapSession { return; } this.grant = next; + if (next) { + this.rememberSignedSession(decodeGrant(next)); + } if (this.useCookies) { try { if (next) { @@ -446,12 +464,14 @@ export default class GleapSession { } }; + /** Erases the grant cookie and the signed-session cookie (cookies turned off). */ eraseGrantCookie = () => { if (!this.sdkKey) { return; } try { eraseGleapCookie(this.grantCookieName()); + eraseGleapCookie(this.signedSessionKey()); } catch (exp) {} }; @@ -474,11 +494,232 @@ export default class GleapSession { const decoded = decodeGrant(grant); if (decoded && decoded.sid === this.session.gleapId) { this.grant = grant; + // A grant from an SDK before the signed-session record: record it now. + this.rememberSignedSession(decoded); this.scheduleGrantRefresh(null); } } catch (exp) {} }; + signedSessionKey = () => `signed-session-${this.sdkKey}`; + + /** Records that the session `decoded.sid` was signed for `decoded.uid` until `decoded.exp`, and arms the expiry check. */ + rememberSignedSession = (decoded) => { + if (!decoded || !decoded.sid || !decoded.uid) { + return; + } + const record = { sid: decoded.sid, uid: decoded.uid, exp: decoded.exp }; + const current = this.signedSession; + if (!current || current.sid !== record.sid || current.uid !== record.uid || current.exp !== record.exp) { + this.signedSession = record; + if (!this.localStorageDisabled) { + try { + saveToGleapCache(this.signedSessionKey(), record); + } catch (exp) {} + } + if (this.useCookies) { + try { + setGleapCookie(this.signedSessionKey(), encodeURIComponent(JSON.stringify(record)), 365); + } catch (exp) {} + } + } + this.armSignedSessionExpiry(); + }; + + forgetSignedSession = () => { + clearTimeout(this.signedSessionExpiryTimeout); + this.signedSessionExpiryTimeout = null; + this.signedSession = null; + try { + saveToGleapCache(this.signedSessionKey(), null); + } catch (exp) {} + if (this.useCookies) { + try { + eraseGleapCookie(this.signedSessionKey()); + } catch (exp) {} + } + }; + + /** The signed-session record for the current session and user, or null. */ + currentSignedSession = () => { + if (!this.signedSession) { + let stored = null; + try { + stored = this.localStorageDisabled ? null : loadFromGleapCache(this.signedSessionKey()); + } catch (exp) {} + if (!stored && this.useCookies) { + try { + const cookie = getGleapCookie(this.signedSessionKey()); + stored = cookie ? JSON.parse(decodeURIComponent(cookie)) : null; + } catch (exp) {} + } + if (stored && typeof stored === 'object' && typeof stored.sid === 'string' && typeof stored.uid === 'string') { + this.signedSession = { sid: stored.sid, uid: stored.uid, exp: typeof stored.exp === 'number' ? stored.exp : null }; + } + } + const record = this.signedSession; + if ( + !record || + !this.session || + !this.session.gleapId || + record.sid !== this.session.gleapId || + this.session.userId === undefined || + this.session.userId === null || + record.uid !== String(this.session.userId) + ) { + return null; + } + return record; + }; + + /** The current session was signed, its signed lifetime has passed and no live grant renewed it. */ + signedSessionExpired = () => { + const record = this.currentSignedSession(); + return !!record && record.exp !== null && record.exp * 1000 <= Date.now() && !this.hasLiveGrant(); + }; + + /** Checks the signed session once its grant has run out (a page left open past the expiry). */ + armSignedSessionExpiry = (at = null) => { + clearTimeout(this.signedSessionExpiryTimeout); + this.signedSessionExpiryTimeout = null; + const record = this.currentSignedSession(); + // Already past (a reload after the expiry): the /sessions answer decides, no timer. + if (!record || record.exp === null || (at === null && record.exp * 1000 <= Date.now())) { + return; + } + const due = at !== null ? at : record.exp * 1000; + // setTimeout overflows above ~24.8 days; the callback re-arms until `due`. + const delay = Math.min(Math.max(0, due - Date.now()), 2147483647); + this.signedSessionExpiryTimeout = setTimeout(() => { + this.signedSessionExpiryTimeout = null; + this.onSignedSessionExpiryDue(due); + }, delay); + }; + + onSignedSessionExpiryDue = (due) => { + if (Date.now() < due - 1000) { + this.armSignedSessionExpiry(due); + return; + } + const record = this.currentSignedSession(); + if (!record || record.exp === null || record.exp * 1000 > Date.now()) { + // Renewed meantime (its own expiry check was armed), or the session changed. + return; + } + // Another tab of the same site renewed it: take its grant from the cookie. + if (this.useCookies) { + try { + const cookie = getGleapCookie(this.grantCookieName()); + const grant = cookie ? decodeURIComponent(cookie) : null; + const decoded = grant ? decodeGrant(grant) : null; + if (decoded && decoded.sid === this.session.gleapId && decoded.exp !== null && decoded.exp * 1000 > Date.now()) { + this.setGrant(grant); + return; + } + } catch (exp) {} + } + if (this.hasLiveGrant()) { + return; + } + // The user is in the messenger: wait until they close it, at most an hour. + if (this.messengerOpen() && Date.now() < record.exp * 1000 + SIGNED_SESSION_GRACE_MS) { + this.armSignedSessionExpiry(Date.now() + SIGNED_SESSION_GRACE_CHECK_MS); + return; + } + // Ask the server: in Signed mode it answers "not signed", which renews + // the session with a fresh token or ends it (startSession). + this.startSession(); + }; + + messengerOpen = () => { + try { + const frameManager = GleapFrameManager.getInstance(); + return !!(frameManager && typeof frameManager.isOpened === 'function' && frameManager.isOpened()); + } catch (exp) { + return false; + } + }; + + /** + * A /sessions answer in Signed mode saying "not signed" for a session whose + * signed lifetime has passed. Tries once to sign it again: an identify already + * on its way, the held token or hash (renewed through the JWT provider when + * it expires soon), else a token from the provider. When none brings a live + * grant, the session ends like clearIdentity (the JWT provider stays) and a + * fresh visitor session starts; a later setUserJwt signs the user in again + * and the server merges the visitor's activity into them. Transient failures + * keep the session. + */ + renewOrEndExpiredSignedSession = () => { + if (this.signedSessionExpiryRunning) { + return; + } + this.signedSessionExpiryRunning = true; + const generation = this.logoutGeneration; + let attempt = null; + try { + attempt = this.renewExpiredSignedSession(); + } catch (exp) {} + const decide = () => { + // onContactSettled gives up after its timeout: an identify still on its way decides first. + if (this.logoutGeneration === generation && (this.identifyInFlight || this.pendingContactUpdates > 0)) { + settle(); + return; + } + this.signedSessionExpiryRunning = false; + if (this.logoutGeneration !== generation || !this.signedSessionExpired() || this.identifyFailedTransiently) { + return; + } + this.endExpiredSignedSession(); + }; + const settle = () => this.onContactSettled(decide, JWT_PROVIDER_TIMEOUT_MS); + Promise.resolve(attempt).then(settle, settle); + }; + + /** The one renewal attempt for an expired signed session. Returns a Promise, or null when nothing was sent. */ + renewExpiredSignedSession = () => { + if (this.identifyInFlight) { + // Its answer decides (onContactSettled waits for it). + return null; + } + const identify = this.lastIdentify; + const generation = this.logoutGeneration; + if (identify && identify.userHash && this.refusedIdentity !== this.refusedIdentityOf(identify.userId, identify.userHash)) { + const token = decodeUserJwt(identify.userHash); + const tokenExpired = token && token.exp !== null && token.exp * 1000 <= Date.now(); + if (!tokenExpired || this.jwtProvider) { + this.markSignedModeIdentify(); + return Promise.resolve( + this.withFreshIdentify(({ userId, userData, userHash }) => { + const fresh = decodeUserJwt(userHash); + if (fresh && fresh.exp !== null && fresh.exp * 1000 <= Date.now()) { + // The provider had nothing newer: the expired token would only be refused. + return; + } + return this.trackContactUpdate(this.sendIdentify(userId, userData, userHash, true)); + }) + ).catch(() => {}); + } + } + if (this.jwtProvider) { + this.signedModeProviderAsked = true; + return this.requestUserJwt().then((token) => { + if (!token || this.logoutGeneration !== generation || this.identifyInFlight) { + return; + } + return this.setUserJwt(token); + }); + } + return null; + }; + + endExpiredSignedSession = () => { + console.info( + 'Gleap: the signed session expired and no fresh identity token arrived, so the user continues as a visitor. Call Gleap.setUserJwt (or set Gleap.setUserJwtProvider) to sign them in again; their visitor activity on this device is merged into their contact.' + ); + this.clearSession(0, true); + }; + /** * Takes `identity.grant` out of a session response so it never lands in the * persisted session. Returns `{ session, grant }`; `grant` is undefined when @@ -535,6 +776,8 @@ export default class GleapSession { this.authTokens = {}; this.authTokensSentFor = null; this.setGrant(null, false); + this.forgetSignedSession(); + this.signedSessionExpiryRunning = false; this.contactVersion++; this.logoutGeneration++; this.signedModeIdentifySent = false; @@ -653,6 +896,9 @@ export default class GleapSession { // A cached session: its grant cookie is read before the session-ready // listeners run, so an identify queued before initialize sees it. this.restoreGrant(); + // A grant for the session this answer switched to (a visitor merged into + // the contact) was recorded before the switch: arm its expiry check now. + this.armSignedSessionExpiry(); if (session.fileAccessToken) this.openRequestedProtectedFile(); clearTimeout(this.fileRefreshTimeout); if (session.fileAccessToken && this.lastIdentify?.userHash) { @@ -977,6 +1223,7 @@ export default class GleapSession { } } this.restoreGrant(); + this.armSignedSessionExpiry(); const self = this; const contactVersionAtSend = this.contactVersion; @@ -1003,7 +1250,19 @@ export default class GleapSession { try { const sessionData = JSON.parse(http.responseText); self.validateSession(sessionData); - self.identifyForSignedModeIfNeeded(); + const answeredIdentity = sessionData && sessionData.identity; + if ( + answeredIdentity && + typeof answeredIdentity === 'object' && + answeredIdentity.mode === 'signed' && + answeredIdentity.signed === false && + self.signedSessionExpired() + ) { + // Signed mode, and this session's signed lifetime has passed. + self.renewOrEndExpiredSignedSession(); + } else { + self.identifyForSignedModeIfNeeded(); + } } catch (exp) {} } else { if (http.status !== 429) { diff --git a/src/GleapSession.test.js b/src/GleapSession.test.js index 370dc522..6bc03067 100644 --- a/src/GleapSession.test.js +++ b/src/GleapSession.test.js @@ -1150,6 +1150,316 @@ describe('grant (signed session credential)', () => { }); }); + describe('signed session expiry (a visitor afterwards, like Intercom on the web)', () => { + const { GleapFrameManager } = require('./Gleap'); + const UNSIGNED_SIGNED_MODE = { identity: { signed: false, grant: null, mode: 'signed' } }; + const sessionsRequests = () => MockXhr.instances.filter((xhr) => xhr.url.endsWith('/sessions')); + const answerUnsigned = (xhr) => xhr.respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, ...UNSIGNED_SIGNED_MODE })); + const token = (seconds = 3600, extra = {}) => jwtOf({ userId: 'user-1', ...extra, exp: inSeconds(seconds) }); + let info; + + beforeEach(() => { + info = jest.spyOn(console, 'info').mockImplementation(() => {}); + }); + afterEach(() => { + info.mockRestore(); + GleapFrameManager.getInstance.mockImplementation(() => ({ sendMessage: jest.fn(), sendSessionUpdate: jest.fn() })); + }); + + // Signs user-1 in on gleap-id-1 with a grant that runs out in `grantSeconds`. + const signedIn = (grantSeconds = 600, jwtSeconds = 3600) => { + const session = readySession(); + session.setUserJwt(token(jwtSeconds)); + respondWithGrant(identifyRequests()[0], grantFor('gleap-id-1', inSeconds(grantSeconds))); + return session; + }; + + // The page is closed, then reopened `laterSeconds` on; the grant cookie expired with the grant. + const reloadLater = (laterSeconds, setup = () => {}) => { + jest.clearAllTimers(); + jest.setSystemTime(Date.now() + laterSeconds * 1000); + MockXhr.instances = []; + const session = buildSession(); + setup(session); + session.startSession(); + return session; + }; + + const expectVisitor = async (session) => { + 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); + expect(cacheStore['signed-session-sdk-key']).toBe(null); + expect(session.lastIdentify).toBe(null); + jest.advanceTimersByTime(0); + const fresh = sessionsRequests()[sessionsRequests().length - 1]; + expect(fresh.headers['Gleap-Id']).toBeUndefined(); + fresh.respond(200, JSON.stringify({ gleapId: 'gleap-id-guest', gleapHash: 'gleap-hash-guest' })); + await flush(); + expect(session.session.gleapId).toBe('gleap-id-guest'); + expect(session.session.userId).toBeFalsy(); + }; + + it('records the signed session (never the grant) when a grant arrives', () => { + const session = signedIn(600); + expect(cacheStore['signed-session-sdk-key']).toEqual({ sid: 'gleap-id-1', uid: 'user-1', exp: inSeconds(600) }); + expect(JSON.stringify(cacheStore['signed-session-sdk-key'])).not.toContain(session.grant); + expect(setGleapCookie).toHaveBeenCalledWith('signed-session-sdk-key', expect.any(String), 365); + }); + + it('a reload after the grant expired, without a token or provider, continues as a fresh visitor', async () => { + signedIn(600); + const session = reloadLater(11 * 60); + expect(sessionsRequests()[0].headers['Gleap-Id']).toBe('gleap-id-1'); + expect(sessionsRequests()[0].headers['Gleap-Grant']).toBeUndefined(); + answerUnsigned(sessionsRequests()[0]); + await expectVisitor(session); + expect(identifyRequests()).toHaveLength(0); + expect(info).toHaveBeenCalledWith(expect.stringContaining('continues as a visitor')); + // The visitor session is final: nothing more is sent. + jest.advanceTimersByTime(10 * 60 * 1000); + await flush(); + expect(sessionsRequests()).toHaveLength(2); + expect(identifyRequests()).toHaveLength(0); + }); + + it('setUserJwt on the visitor session signs the user in again (the server merges the visitor into them)', async () => { + signedIn(600); + const session = reloadLater(11 * 60); + answerUnsigned(sessionsRequests()[0]); + await expectVisitor(session); + const fresh = token(); + session.setUserJwt(fresh); + expect(identifyRequests()).toHaveLength(1); + expect(identifyRequests()[0].headers['Gleap-Id']).toBe('gleap-id-guest'); + expect(JSON.parse(identifyRequests()[0].body).userHash).toBe(fresh); + respondWithGrant(identifyRequests()[0], grantFor('gleap-id-1')); + await flush(); + expect(session.session.userId).toBe('user-1'); + expect(session.hasLiveGrant()).toBe(true); + expect(cacheStore['signed-session-sdk-key']).toEqual(expect.objectContaining({ sid: 'gleap-id-1', uid: 'user-1' })); + }); + + it('a JWT provider with a fresh token keeps the user signed (asked once)', async () => { + signedIn(600); + const fresh = token(); + const provider = jest.fn(() => Promise.resolve(fresh)); + const session = reloadLater(11 * 60, (s) => s.setUserJwtProvider(provider)); + answerUnsigned(sessionsRequests()[0]); + await flush(); + expect(provider).toHaveBeenCalledTimes(1); + expect(identifyRequests()).toHaveLength(1); + expect(JSON.parse(identifyRequests()[0].body).userHash).toBe(fresh); + respondWithGrant(identifyRequests()[0], grantFor('gleap-id-1')); + await flush(); + expect(session.session.gleapId).toBe('gleap-id-1'); + expect(session.session.userId).toBe('user-1'); + expect(session.hasLiveGrant()).toBe(true); + expect(info).not.toHaveBeenCalled(); + }); + + it('a JWT provider that returns null: the session ends (provider asked once, kept afterwards)', async () => { + signedIn(600); + const provider = jest.fn(() => null); + const session = reloadLater(11 * 60, (s) => s.setUserJwtProvider(provider)); + answerUnsigned(sessionsRequests()[0]); + await expectVisitor(session); + expect(provider).toHaveBeenCalledTimes(1); + expect(session.jwtProvider).toBe(provider); + expect(identifyRequests()).toHaveLength(0); + }); + + it('setUserJwt sent before the /sessions answer: its grant keeps the user signed', async () => { + signedIn(600); + const fresh = token(); + const session = reloadLater(11 * 60, (s) => s.setUserJwt(fresh)); + expect(identifyRequests()).toHaveLength(1); + answerUnsigned(sessionsRequests()[0]); + await flush(); + jest.advanceTimersByTime(30 * 1000); + await flush(); + expect(session.session.gleapId).toBe('gleap-id-1'); + respondWithGrant(identifyRequests()[0], grantFor('gleap-id-1')); + await flush(); + expect(session.session.userId).toBe('user-1'); + expect(session.hasLiveGrant()).toBe(true); + expect(identifyRequests()).toHaveLength(1); + expect(info).not.toHaveBeenCalled(); + }); + + it('setUserJwt with a token the server refuses: the session ends', async () => { + signedIn(600); + const session = reloadLater(11 * 60, (s) => s.setUserJwt(token(3600, { plan: 'x' }))); + answerUnsigned(sessionsRequests()[0]); + identifyRequests()[0].respond(401, JSON.stringify({ detail: { value: 'token_invalid' } })); + await expectVisitor(session); + }); + + it('an expired held token without a provider is not sent (it would only be refused)', async () => { + // The page stayed closed past both the token and the grant; the app hands the same old token again. + signedIn(600, 300); + const session = reloadLater(11 * 60, (s) => s.lastIdentify = { userId: 'user-1', userData: {}, userHash: token(-60) }); + answerUnsigned(sessionsRequests()[0]); + await expectVisitor(session); + expect(identifyRequests()).toHaveLength(0); + }); + + it('a renewal that fails on the network keeps the session (no drop while offline)', async () => { + signedIn(600); + const session = reloadLater(11 * 60, (s) => s.setUserJwt(token())); + answerUnsigned(sessionsRequests()[0]); + identifyRequests()[0].error(); + await flush(); + jest.advanceTimersByTime(60 * 1000); + await flush(); + expect(session.session.gleapId).toBe('gleap-id-1'); + expect(session.session.userId).toBe('user-1'); + expect(info).not.toHaveBeenCalled(); + }); + + it('a session identified without a token stays identified (no signed lifetime to expire)', async () => { + // identify('user-1', data) without a token: an identified session, never a grant. + const first = readySession(); + first.identifySession('user-1', { name: 'Bec Changed' })?.catch?.(() => {}); + identifyRequests()[0].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, ...UNSIGNED_SIGNED_MODE })); + expect(cacheStore['signed-session-sdk-key']).toBeUndefined(); + const session = reloadLater(30 * 24 * 3600); + answerUnsigned(sessionsRequests()[0]); + await flush(); + jest.advanceTimersByTime(60 * 1000); + expect(session.session.gleapId).toBe('gleap-id-1'); + expect(session.session.userId).toBe('user-1'); + expect(info).not.toHaveBeenCalled(); + }); + + it('a grant that is merely missing (cookie gone) before its expiry keeps today\'s behaviour', async () => { + signedIn(600); + const session = reloadLater(5 * 60); + answerUnsigned(sessionsRequests()[0]); + await flush(); + jest.advanceTimersByTime(0); + expect(session.session.gleapId).toBe('gleap-id-1'); + expect(session.session.userId).toBe('user-1'); + expect(info).not.toHaveBeenCalled(); + }); + + it('a Classic answer (no identity) after the expiry keeps the session', async () => { + signedIn(600); + const session = reloadLater(11 * 60); + sessionsRequests()[0].respond(200, JSON.stringify({ ...CACHED_IDENTIFIED_SESSION })); + await flush(); + jest.advanceTimersByTime(0); + expect(session.session.userId).toBe('user-1'); + expect(info).not.toHaveBeenCalled(); + }); + + it('a visitor signed in through a merge (the answer names another session) gets the expiry check too', async () => { + seedCachedSession(); + cacheStore['session-sdk-key'] = { gleapId: 'gleap-id-guest', gleapHash: 'gleap-hash-guest' }; + const session = buildSession(); + session.startSession(); + session.setUserJwt(token(300)); + identifyRequests()[0].respond( + 200, + JSON.stringify({ ...CACHED_IDENTIFIED_SESSION, identity: { signed: true, kid: 'k1', kind: 'jwt', grant: grantFor('gleap-id-1', inSeconds(600)) } }) + ); + expect(session.session.gleapId).toBe('gleap-id-1'); + expect(session.signedSessionExpiryTimeout).not.toBe(null); + session.lastIdentify = null; + MockXhr.instances = []; + jest.advanceTimersByTime(600 * 1000); + await flush(); + expect(sessionsRequests()).toHaveLength(1); + }); + + it('clearSession forgets the signed session', () => { + const session = signedIn(600); + session.clearSession(0, false); + expect(cacheStore['signed-session-sdk-key']).toBe(null); + expect(eraseGleapCookie).toHaveBeenCalledWith('signed-session-sdk-key'); + expect(session.signedSessionExpiryTimeout).toBe(null); + }); + + describe('a page left open past the expiry', () => { + it('asks the server at the expiry and continues as a visitor when nothing renews it', async () => { + // Token valid 5 min, grant 10 min, no provider: the refresh a minute before the end sends the expired token. + const session = signedIn(600, 300); + MockXhr.instances = []; + jest.advanceTimersByTime(540 * 1000); + await flush(); + expect(identifyRequests()).toHaveLength(1); + identifyRequests()[0].respond(401, JSON.stringify({ detail: { value: 'token_expired' } })); + await flush(); + expect(sessionsRequests()).toHaveLength(0); + + jest.advanceTimersByTime(60 * 1000); + await flush(); + expect(sessionsRequests()).toHaveLength(1); + expect(sessionsRequests()[0].headers['Gleap-Id']).toBe('gleap-id-1'); + answerUnsigned(sessionsRequests()[0]); + await expectVisitor(session); + // The refused token is not sent again. + expect(identifyRequests()).toHaveLength(1); + }); + + it('a renewed grant re-arms the check: nothing ends', async () => { + const session = signedIn(600); + MockXhr.instances = []; + jest.advanceTimersByTime(540 * 1000); + await flush(); + respondWithGrant(identifyRequests()[0], grantFor('gleap-id-1', inSeconds(600))); + jest.advanceTimersByTime(120 * 1000); + await flush(); + expect(sessionsRequests()).toHaveLength(0); + expect(session.hasLiveGrant()).toBe(true); + }); + + it('takes a grant another tab renewed from the cookie', async () => { + const session = signedIn(600); + session.lastIdentify = null; // no refresh from this tab + const renewed = grantFor('gleap-id-1', inSeconds(1800)); + getGleapCookie.mockImplementation((name) => (name === 'gleap-grant-sdk-key' ? encodeURIComponent(renewed) : null)); + MockXhr.instances = []; + jest.advanceTimersByTime(600 * 1000); + await flush(); + expect(session.grant).toBe(renewed); + expect(sessionsRequests()).toHaveLength(0); + }); + + it('waits while the messenger is open, then ends once it closes (at most an hour)', async () => { + let open = true; + GleapFrameManager.getInstance.mockImplementation(() => ({ sendMessage: jest.fn(), sendSessionUpdate: jest.fn(), isOpened: () => open })); + const session = signedIn(600); + session.lastIdentify = null; + MockXhr.instances = []; + jest.advanceTimersByTime(600 * 1000 + 30 * 60 * 1000); + await flush(); + expect(sessionsRequests()).toHaveLength(0); + open = false; + jest.advanceTimersByTime(60 * 1000); + await flush(); + expect(sessionsRequests()).toHaveLength(1); + answerUnsigned(sessionsRequests()[0]); + await expectVisitor(session); + }); + + it('the messenger left open: the session still ends an hour after the expiry', async () => { + GleapFrameManager.getInstance.mockImplementation(() => ({ sendMessage: jest.fn(), sendSessionUpdate: jest.fn(), isOpened: () => true })); + const session = signedIn(600); + session.lastIdentify = null; + MockXhr.instances = []; + jest.advanceTimersByTime(600 * 1000 + 59 * 60 * 1000); + await flush(); + expect(sessionsRequests()).toHaveLength(0); + jest.advanceTimersByTime(2 * 60 * 1000); + await flush(); + expect(sessionsRequests()).toHaveLength(1); + }); + }); + }); + it('drops an identify answer that lands after a logout', async () => { const session = readySession(); session.setUserJwt(jwtOf({ userId: 'user-1', exp: inSeconds(3600) })); diff --git a/src/GleapUserJwt.js b/src/GleapUserJwt.js index 0881c5f1..10538b85 100644 --- a/src/GleapUserJwt.js +++ b/src/GleapUserJwt.js @@ -52,7 +52,7 @@ export const decodeUserJwt = (token) => { }; /** - * Reads `{ sid, exp }` from a grant (the server-signed session credential an + * Reads `{ sid, uid, iat, 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) => { @@ -66,6 +66,7 @@ export const decodeGrant = (grant) => { } return { sid: typeof payload.sid === 'string' ? payload.sid : null, + uid: typeof payload.uid === 'string' ? payload.uid : null, iat: typeof payload.iat === 'number' ? payload.iat : null, exp: typeof payload.exp === 'number' ? payload.exp : null, };