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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
## 2.0.1

### Bug Fix: JWT logout drops completion callback, breaking `addLogoutHandler`

`_sessionDestroyJwt`'s success and no-refresh-token early-return paths both forgot to invoke the user-supplied callback. The HMAC equivalent (`sessionDestroy` proper) does invoke it on both success and error. `addLogoutHandler` puts `location.href = '/'` in that callback, so in JWT mode the redirect never fired — clicking logout cleared tokens locally and revoked them server-side, but the user stayed on the post-logout page, looking like they were still logged in.

Fix: invoke the completion callback on **all three** exit paths of `_sessionDestroyJwt` — success, error, and the `!refreshToken` early return. Matches the HMAC `sessionDestroy` contract.

Regression coverage in `tests/kyte.test.js`: three new tests cover each path, mocking `$.ajax` and asserting the callback fires + tokens are cleared.

Customer-visible impact: Shipyard 2.0.0 users on JWT mode (default) couldn't actually log out from the UI prior to 2.0.1.

## 2.0.0

### Major: dual-auth support (HMAC + JWT)
Expand Down
20 changes: 18 additions & 2 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.0';
static VERSION = '2.0.1';
/** **************** */

/**
Expand Down Expand Up @@ -763,6 +763,11 @@ class Kyte {
* Clearing local first would leak the still-active refresh token
* server-side if the request never goes out; doing it after means
* a transient failure still cleans up locally on the next refresh.
*
* The `error` parameter is misnamed for historical reasons — it's
* really a "completion" callback invoked on both success and
* failure, matching HMAC sessionDestroy's contract. addLogoutHandler
* relies on this to redirect to the login page after logout.
*/
_sessionDestroyJwt = (error = null) => {
var obj = this;
Expand All @@ -778,7 +783,13 @@ class Kyte {
};

if (!refreshToken) {
// No server-side token to revoke — just clear local state
// and notify the caller so they can redirect, matching the
// HMAC sessionDestroy contract.
obj._jwtClearTokens();
if (typeof error === 'function') {
error(null);
}
return;
}

Expand All @@ -789,7 +800,12 @@ class Kyte {
url: obj.url + '/jwt/logout',
contentType: 'application/json',
data: JSON.stringify({ refresh_token: refreshToken }),
success: function () { obj._jwtClearTokens(); },
success: function (response) {
obj._jwtClearTokens();
if (typeof error === 'function') {
error(response);
}
},
error: finish
});
}
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.0",
"version": "2.0.1",
"description": "Browser-side JavaScript SDK for the Kyte low-code platform",
"private": true,
"main": "kyte-source.js",
Expand Down
70 changes: 70 additions & 0 deletions tests/kyte.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,73 @@ describe('Kyte — utilities', () => {
expect(k.getNestedValue(obj, 'foo.missing.baz')).toBeUndefined();
});
});

// JWT logout — regression coverage for the addLogoutHandler contract.
// _sessionDestroyJwt accepts a completion callback (misnamed `error` for
// historical compatibility with HMAC sessionDestroy) and MUST invoke it on
// every exit path: success, error, and the no-refresh-token early return.
// Without all three, the addLogoutHandler() flow (`location.href = '/'` in
// the callback) silently no-ops on successful logout and users get stuck on
// the post-logout page with cleared tokens.
describe('Kyte — JWT sessionDestroy', () => {
it('invokes the completion callback when no refresh token is held', () => {
const k = new Kyte('https://api.test', null, null, null, null, { authMode: 'jwt' });
// No login happened → no refresh token in state.
expect(k.jwtRefreshToken).toBeFalsy();

const onComplete = vi.fn();
k.sessionDestroy(onComplete);

expect(onComplete).toHaveBeenCalledOnce();
});

it('invokes the completion callback after successful POST /jwt/logout', () => {
const k = new Kyte('https://api.test', null, null, null, null, { authMode: 'jwt' });
k.jwtRefreshToken = 'kref_v1_fake_test_token';
k.jwtAccessToken = 'header.payload.sig';
k.jwtAccessExpiresAt = Date.now() + 900_000;

// $.ajax is a jQuery function — replace it with our own and restore after.
// (vi.spyOn doesn't work on it because the descriptor isn't configurable.)
const realAjax = globalThis.$.ajax;
let captured;
globalThis.$.ajax = (opts) => {
captured = opts;
opts.success({ ok: true });
};

try {
const onComplete = vi.fn();
k.sessionDestroy(onComplete);

expect(captured.url).toBe('https://api.test/jwt/logout');
expect(JSON.parse(captured.data)).toEqual({ refresh_token: 'kref_v1_fake_test_token' });

expect(onComplete).toHaveBeenCalledOnce();
expect(k.jwtRefreshToken).toBeNull();
expect(k.jwtAccessToken).toBeNull();
} finally {
globalThis.$.ajax = realAjax;
}
});

it('invokes the completion callback when POST /jwt/logout fails', () => {
const k = new Kyte('https://api.test', null, null, null, null, { authMode: 'jwt' });
k.jwtRefreshToken = 'kref_v1_fake_test_token';

const realAjax = globalThis.$.ajax;
globalThis.$.ajax = (opts) => {
opts.error({ status: 500, statusText: 'Internal Server Error' });
};

try {
const onComplete = vi.fn();
k.sessionDestroy(onComplete);

expect(onComplete).toHaveBeenCalledOnce();
expect(k.jwtRefreshToken).toBeNull();
} finally {
globalThis.$.ajax = realAjax;
}
});
});
Loading