Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
17 changes: 14 additions & 3 deletions kyte-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
**/
class Kyte {
/** KyteJS Version # */
static VERSION = '2.0.1';
static VERSION = '2.0.2';
/** **************** */

/**
Expand Down Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
75 changes: 75 additions & 0 deletions tests/kyte.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading