diff --git a/CHANGELOG.md b/CHANGELOG.md index f811d83..222510c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## 2.0.2 + +### Bug Fix: JWT refresh cookie outlives server-side validity + +`_jwtStoreTokens` hardcoded the `kyte_jwt_refresh` cookie TTL to **30 days** (`60 * 24 * 30` minutes), regardless of what the server's refresh-token TTL actually allowed. Server defaulted to 7 days in 4.4.x; even there, the cookie outlived the server by 23 days. With kyte-php 4.5.0 dropping the server-side default to 4 hours, the mismatch became a real problem — closing the browser left a "zombie" refresh cookie that the server had long since stopped honoring. + +Fix: derive the cookie TTL from `response.refresh_expires_at` (unix seconds, set by the server in `/jwt/login` and `/jwt/refresh` responses). Cookie now expires precisely when the server's refresh token expires. If the server omits `refresh_expires_at` (older deployments / unexpected response shape), fall through to a **session cookie** (browser-session lifetime) rather than the old 30-day hardcode — safer-by-default, the user gets logged out when they close the browser. + +Regression coverage in `tests/kyte.test.js`: +1. Cookie TTL matches `refresh_expires_at` +2. Missing `refresh_expires_at` → session cookie +3. Asserts the 30-day literal (`43200` minutes) never reappears + sanity bound < 1 week + +No API surface change for the consuming app. Recommended pairing with kyte-php 4.5.0 (`KYTE_JWT_FAMILY_MAX_LIFETIME` introduces a 12-hour absolute session cap; the cookie-TTL fix here ensures the client-side cookie expires in lockstep). + ## 2.0.1 ### Bug Fix: JWT logout drops completion callback, breaking `addLogoutHandler` diff --git a/kyte-source.js b/kyte-source.js index dc88e4c..26a7a38 100644 --- a/kyte-source.js +++ b/kyte-source.js @@ -17,7 +17,7 @@ **/ class Kyte { /** KyteJS Version # */ - static VERSION = '2.0.1'; + static VERSION = '2.0.2'; /** **************** */ /** @@ -979,10 +979,21 @@ class Kyte { // Cookie TTLs: access cookie matches the JWT exp (browser will drop // it on its own around the same time the JWT expires). Refresh - // cookie is sized larger — server-side TTL is the source of truth. + // cookie is derived from `refresh_expires_at` (unix seconds) so the + // cookie expires precisely when the server's refresh token expires — + // no zombie cookie lingering past the server's TTL. If the server + // omits it (older deployments / belt-and-suspenders), fall through + // to a session cookie (null = browser-session lifetime) rather than + // the old 30-day hardcode that long-outlived server-side validity. var accessMinutes = expiresIn > 0 ? Math.ceil(expiresIn / 60) : null; + var refreshExpiresAt = typeof response.refresh_expires_at === 'number' ? response.refresh_expires_at : 0; + var refreshMinutes = null; + if (refreshExpiresAt > 0) { + var secondsRemaining = refreshExpiresAt - Math.floor(Date.now() / 1000); + refreshMinutes = secondsRemaining > 0 ? Math.ceil(secondsRemaining / 60) : -1; + } this.setCookie('kyte_jwt_access', this.jwtAccessToken || '', accessMinutes, this.sessionCrossDomain); - this.setCookie('kyte_jwt_refresh', this.jwtRefreshToken || '', 60 * 24 * 30, this.sessionCrossDomain); + this.setCookie('kyte_jwt_refresh', this.jwtRefreshToken || '', refreshMinutes, this.sessionCrossDomain); this.setCookie('kyte_jwt_expires', String(this.jwtAccessExpiresAt), accessMinutes, this.sessionCrossDomain); } diff --git a/package.json b/package.json index 3e6b3ae..f69df62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@keyqcloud/kyte-api-js", - "version": "2.0.1", + "version": "2.0.2", "description": "Browser-side JavaScript SDK for the Kyte low-code platform", "private": true, "main": "kyte-source.js", diff --git a/tests/kyte.test.js b/tests/kyte.test.js index d7d797f..dae9a3a 100644 --- a/tests/kyte.test.js +++ b/tests/kyte.test.js @@ -173,3 +173,78 @@ describe('Kyte — JWT sessionDestroy', () => { } }); }); + +// _jwtStoreTokens cookie-TTL derivation. Prior to v2.0.2 the refresh +// cookie was hardcoded to a 30-day TTL regardless of what the server's +// refresh token actually allowed — so the cookie outlived server-side +// validity by weeks. v2.0.2 derives the TTL from response.refresh_expires_at +// and falls back to a browser-session cookie when the server omits it. +describe('Kyte — JWT cookie TTL derivation', () => { + // Capture every setCookie call so we can inspect TTLs without parsing + // jsdom's document.cookie (which drops expires anyway). + function captureSetCookie(k) { + const calls = []; + k.setCookie = function (cname, cvalue, minutes = null) { + calls.push({ cname, cvalue, minutes }); + }; + return calls; + } + + it('derives kyte_jwt_refresh TTL from response.refresh_expires_at', () => { + const k = new Kyte('https://api.test', null, null, null, null, { authMode: 'jwt' }); + const calls = captureSetCookie(k); + + // Server says refresh token expires 4 hours from now. + const refreshExpiresAt = Math.floor(Date.now() / 1000) + 14400; + k._jwtStoreTokens({ + access_token: 'a.b.c', + refresh_token: 'kref_v1_xyz', + expires_in: 900, + refresh_expires_at: refreshExpiresAt, + }); + + const refreshCall = calls.find((c) => c.cname === 'kyte_jwt_refresh'); + expect(refreshCall).toBeDefined(); + // 4h = 240 minutes (±1 for the rounding window). + expect(refreshCall.minutes).toBeGreaterThanOrEqual(239); + expect(refreshCall.minutes).toBeLessThanOrEqual(241); + }); + + it('falls back to a session cookie when refresh_expires_at is omitted', () => { + const k = new Kyte('https://api.test', null, null, null, null, { authMode: 'jwt' }); + const calls = captureSetCookie(k); + + k._jwtStoreTokens({ + access_token: 'a.b.c', + refresh_token: 'kref_v1_xyz', + expires_in: 900, + // refresh_expires_at intentionally absent + }); + + const refreshCall = calls.find((c) => c.cname === 'kyte_jwt_refresh'); + expect(refreshCall).toBeDefined(); + // null minutes → no `expires` header → browser-session cookie + // (closes when browser closes). Safer than the old 30-day hardcode. + expect(refreshCall.minutes).toBeNull(); + }); + + it('does not write a 30-day TTL anymore', () => { + // Regression guard: the v2.0.1 bug used `60 * 24 * 30 = 43200` minutes. + // If anyone re-introduces a literal large number here, the test catches it. + const k = new Kyte('https://api.test', null, null, null, null, { authMode: 'jwt' }); + const calls = captureSetCookie(k); + + k._jwtStoreTokens({ + access_token: 'a.b.c', + refresh_token: 'kref_v1_xyz', + expires_in: 900, + refresh_expires_at: Math.floor(Date.now() / 1000) + 14400, + }); + + const refreshCall = calls.find((c) => c.cname === 'kyte_jwt_refresh'); + // 43200 minutes = 30 days — must not appear. + expect(refreshCall.minutes).not.toBe(43200); + // Sanity bound: any sane refresh TTL is under a week (10080 min). + expect(refreshCall.minutes).toBeLessThan(10080); + }); +});