Skip to content

feat: setUserJwt, setUserJwtProvider and setAuthTokens (customer identity) - #203

Merged
boehlerlukas merged 9 commits into
masterfrom
feat/customer-identity
Nov 7, 2026
Merged

boehlerlukas merged 9 commits into
masterfrom
feat/customer-identity

Conversation

@boehlerlukas

@boehlerlukas boehlerlukas commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Customer identity and authentication, JS SDK part. Built against .handover/customer-identity/CONTRACT.md sections 2, 3, 5 and 6. Server side: branch feat/customer-identity of the Server repo. Nothing changes on the wire: the token travels in the existing userHash of POST /sessions/identify.

Methods

  • Gleap.setUserJwt(token): Promise<void> (§2, §6): decodes the JWT payload (base64url JSON, no verification), takes userId or user_id (string or number) and sends the existing identify with userHash = token and no contact fields. A token that is not three non-empty base64url parts, has no user id or is over 4096 chars gets a console warning and no request. Memory only. The once-only signed-identify record (gleapId:userId) gets a fingerprint of the JWT appended, so a new token is sent once even when nothing else changed; legacy hex hashes keep the old key, so existing caches are untouched.
  • Gleap.setUserJwtProvider(fn) (§3, §6): called on identify 401 token_expired (the identify is retried once with the new token) and before a file-access renewal or an identify replay when the stored token's exp is within 5 minutes. One call in flight, 10 s timeout, 30 s pause after a failure. token_expired / token_invalid warn and never clear the local session; signature_required and other 4xx behave as before.
  • Gleap.setAuthTokens(tokens): Promise<void> (§5): POST /sessions/auth-tokens with { tokens } and the usual headers, after any identify in flight has settled. Names must match ^[a-zA-Z][a-zA-Z0-9_-]{0,39}$ (others skipped with a warning), null deletes. Kept in memory and resent after a signed identify on a session they were not sent to yet. A 401 warns and keeps the session.
  • clearIdentity() also drops the token and the auth tokens; the provider stays set.
  • identify(userId, data, userHash) is unchanged; a JWT passed as userHash gets the same expiry handling.

Types and JSDoc in index.d.ts; README documents setUserJwt first with a backend sample, then the provider, auth tokens, and identify as the legacy call.

Tested

npm test: 36 suites, 630 tests pass. npm run build compiles (build output not committed, as with other feature PRs).

New tests (src/GleapUserJwt.test.js, src/GleapSession.test.js, XHR mocked like the existing session tests):

  • payload decoding: userId / user_id, numbers, UTF-8, bad shapes (2 or 4 parts, empty part, non-base64url, non-JSON, missing / empty / object user id), oversize
  • identify body carries only userId + token; bad token sends nothing
  • same token sent once, new token for the same user sent once more; legacy hash key unchanged
  • token_expired: provider called once, retry with the new token, a second expiry does not loop; parallel callers share one provider call; 30 s pause after failure; 10 s timeout
  • token_expired and token_invalid keep the local session
  • provider asked before a file-access renewal when the token expires within 5 minutes
  • auth-token name validation, null delete, 401 keeps the session, resend after a signed identify on a new session, clearIdentity drops tokens and keeps the provider

Not run against a live server.

Final-review fixes (4987243)

  • Grant refresh cannot loop. The delay comes from the grant's own lifetime (exp - iat) counted from when it arrived, minus a minute, never under 30 s; at most one refresh identify per minute; skipped while one is in flight. A 60-70 s grant or a fast device clock used to re-fire at once on every new grant. A grant restored from the cookie (no receipt time) uses the device clock, still at least 30 s out.
  • Re-minted tokens are not re-identified. "Already sent" compares the token's kid plus its claims without iat/exp/nbf/jti (was: the raw token string). A backend that mints a fresh token per page load no longer causes an identify on every load (Classic projects included); a changed claim or key is still sent once.
  • setUserJwt never rejects. Refusals are logged where they are read; a network error or other HTTP failure logs setUserJwt could not identify the user (...) and resolves. index.d.ts: "Never rejects; refusals are logged."
  • signature_required is logged: "Gleap: this project enforces identity verification and the identify had no valid signature (signature_required). Pass a token from your backend to setUserJwt."
  • Types: setUserJwtProvider(provider: (() => string | null | Promise<string | null>) | null). A provider returning null counts as "no token now" (30 s pause, no warning).
  • Gleap.setSessionDuration(ms) as a method (the initialize option stays), the same API as the mobile SDKs; README line.
  • Grant cookie gleap-grant-<apiKey>: SameSite=Lax, and Secure on https (setGleapCookie(..., credential = true); the session cookie is unchanged).

Checks: npm test 36 suites / 645 tests pass (new: refresh timing, short grant, fast clock, in flight, re-minted token, signature_required, never rejects, cookie attributes); npm run build passes (build/ not committed).

Final re-check fixes (c3a0c03)

Contract addition (Server in parallel): in Signed mode the /sessions and identify answers for an identified session without a grant carry identity: { signed: false, grant: null, mode: 'signed' }; Classic answers carry no identity.

  • Returning users get a grant after a project switches to Signed. When an answer says identity.mode === 'signed', the session is identified, a token or hash is held and no live grant exists, one identify goes out per session load even though the "already sent" fingerprint matches. Once per load (reset only by clearSession), at most once a minute, never a loop; covers both orders (app identifies before or after the /sessions answer).
  • Logout race. A logout generation (bumped only in clearSession) is recorded when an identify or partial update goes out; an answer that lands after a logout is dropped, so it cannot bring back user A after clearIdentity().
  • signature_required for another user than the session's clears the session: B does not keep A's session.
  • Refresh robustness. A grant refresh that failed, was skipped (identify in flight) or came back without a new grant is tried again after 60 s. An identify that failed on the network or with a 5xx is replayed on the browser online event.
  • Expired tokens. A provider token whose exp is already past counts as a provider failure (30 s pause). A refused token (token_expired, token_invalid, signature_required) is remembered by a memory-only fingerprint and not resent on every render; a new token is sent.
  • setUserJwt before initialize waits for the cached session and its grant cookie, so a live cached grant costs no identify.
  • setUseCookies(false) erases the grant cookie (also when called before initialize).

Checks: npm test 36 suites / 656 tests pass (11 new: signed mode both orders + no loop + Classic / live grant send nothing, logout race, signature_required user switch, refused token not resent, expired provider token, online replay, failed refresh retried after a minute, setUserJwt before initialize, cookies off; 10 of them fail on the previous commit). npm run build passes (build/ not committed). Not run against a live server.

🤖 Generated with Claude Code

boehlerlukas and others added 9 commits November 5, 2026 20:19
Customer identity (contract §2, §3, §5, §6): one HS256 JWT signed by the
app's backend is the whole identify.

- Gleap.setUserJwt(token) decodes the payload (no verification), takes
  userId / user_id and sends the existing identify with userHash = token and
  no contact fields. Bad shape, no user id or over 4096 chars: console
  warning, no request. Memory only. The once-only signed-identify record adds
  a fingerprint of a JWT, so a new token is sent once even when nothing else
  changed; legacy hashes keep the gleapId:userId key.
- Gleap.setUserJwtProvider(fn) is asked on identify 401 token_expired (one
  retry with the new token) and before a file-access renewal or identify
  replay when the token expires within 5 minutes. One call in flight, 10 s
  timeout, 30 s pause after a failure. token_expired / token_invalid never
  clear the local session.
- Gleap.setAuthTokens(tokens) posts to /sessions/auth-tokens; names checked
  against ^[a-zA-Z][a-zA-Z0-9_-]{0,39}$, null deletes, kept in memory and
  resent after a signed identify on a session they were not sent to; 401
  warns and keeps the session.
- clearIdentity drops the token and auth tokens; the provider stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A signed identify now returns identity.grant. The SDK keeps it in memory
and in the gleap-grant-<apiKey> cookie (same domain rules as the session
cookie, expiring with the grant), never in the persisted session. It is
restored on reload for the same session, sent as Gleap-Grant on every SDK
request, pushed to the messenger and AI chatbar as a top-level grant field
of session-update, and refreshed a minute before it expires.

clearIdentity drops it and its cookie (the provider stays). A 400
identity_mismatch drops the local session and retries once as a fresh
visitor. getIdentity returns the grant; initialize takes sessionDuration
(ms), sent with identify. Gleap-Grant is masked in captured network logs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cts, signed cookie

- Grant refresh: delay from the grant's own lifetime (exp - iat) counted from
  receipt, at least 30 s, at most one refresh identify a minute, skipped while
  one is in flight. A short grant or a fast device clock no longer loops.
- A token re-minted per page load (same kid and claims, new iat/exp/jti) is not
  identified again; a changed claim or key is.
- setUserJwt never rejects: refusals and network/server errors are logged.
- signature_required is logged.
- Gleap.setSessionDuration(ms) next to the initialize option.
- setUserJwtProvider accepts null (removes it) and a provider returning null.
- gleap-grant-<apiKey> cookie: SameSite=Lax, Secure on https.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…fresh retries

- Signed mode without a grant on an identified session: one identify per
  session load with the held token or hash, even when it was sent before
  (at most once a minute, never a loop).
- An identify answer that lands after clearSession is dropped (logout
  generation), as is a partial update answer.
- signature_required for another user than the session's clears the session.
- A failed or skipped grant refresh is tried again a minute later; a failed
  identify is replayed when the browser comes back online.
- A provider token that already expired counts as a provider failure; a
  refused token is not resent on every render (memory-only fingerprint).
- setUserJwt before initialize decides once the cached session and its grant
  cookie are loaded, so a live grant costs no identify.
- setUseCookies(false) erases the grant cookie.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g, changelog

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…identify

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@boehlerlukas
boehlerlukas merged commit 1db8435 into master Nov 7, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant