From 35ae5dc9aa964ebed2112d91fce1eb74728418f5 Mon Sep 17 00:00:00 2001 From: Pavel Loparev Date: Thu, 16 Jul 2026 19:00:03 +0300 Subject: [PATCH 1/2] AUT-1496 Re-authenticate instead of refresh-looping on session-capped token Keycloak caps a refreshed token's refreshExpiresIn to whatever remains of the parent SSO session. SmartlingAuthApi.refreshToken() only fell back to authenticate() when the refresh call threw, never when it succeeded but returned an already near-expiry session-capped token - same bug class fixed in authentication-api-sdk (AUT-1473) and java-api-sdk (AUT-1474). Co-Authored-By: Claude Sonnet 5 --- api/auth/index.ts | 12 +++++++++++- package-lock.json | 4 ++-- package.json | 2 +- test/auth.spec.ts | 31 +++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/api/auth/index.ts b/api/auth/index.ts index 4f39e5a..b254f69 100644 --- a/api/auth/index.ts +++ b/api/auth/index.ts @@ -41,13 +41,19 @@ export class SmartlingAuthApi extends SmartlingBaseApi implements AccessTokenPro this.logger.debug(`Refresh token with: ${JSON.stringify(this.response, SmartlingBaseApi.sensitiveReplacer)}`); - return await this.makeRequest( + const refreshedToken: AccessTokenDto = await this.makeRequest( "post", `${this.entrypoint}/authenticate/refresh`, JSON.stringify({ refreshToken: this.response.refreshToken }) ); + + if (!this.isSessionCapped(refreshedToken)) { + return refreshedToken; + } + + this.logger.debug(`Refreshed token has a session-capped lifetime (${refreshedToken.refreshExpiresIn}s); re-authenticating.`); } this.logger.debug(`Can't refresh, doing re-auth with: ${JSON.stringify(this.response, SmartlingBaseApi.sensitiveReplacer)}`); @@ -55,6 +61,10 @@ export class SmartlingAuthApi extends SmartlingBaseApi implements AccessTokenPro return await this.authenticate(); } + isSessionCapped(response: AccessTokenDto): boolean { + return response.refreshExpiresIn < this.ttlCorrectionSec; + } + resetRequestTimeStamp(): void { this.requestTimestamp = this.time(); } diff --git a/package-lock.json b/package-lock.json index 40882f9..3c9e5bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "smartling-api-sdk-nodejs", - "version": "2.34.0", + "version": "2.35.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "smartling-api-sdk-nodejs", - "version": "2.34.0", + "version": "2.35.1", "license": "ISC", "dependencies": { "@types/mocha": "^9.0.0", diff --git a/package.json b/package.json index f6a7608..a623331 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smartling-api-sdk-nodejs", - "version": "2.35.0", + "version": "2.35.1", "description": "Package for Smartling API", "main": "built/index.js", "engines": { diff --git a/test/auth.spec.ts b/test/auth.spec.ts index cc5122a..b35752f 100644 --- a/test/auth.spec.ts +++ b/test/auth.spec.ts @@ -72,6 +72,7 @@ describe("Auth class tests.", () => { authTokenExistsStub.returns(true); authTokenCanBeRenewedStub.returns(true); auth.response = { refreshToken: "test_refresh_token" }; + authMakeRequestStub.returns({ accessToken: "refreshed_token", refreshExpiresIn: 3600 }); await auth.refreshToken(); @@ -90,6 +91,36 @@ describe("Auth class tests.", () => { sinon.assert.notCalled(authAuthenticateStub); }); + it("Token exists, can be renewed, and refresh returns a normal (non-capped) token: refreshed token is returned as-is.", async () => { + authTokenExistsStub.returns(true); + authTokenCanBeRenewedStub.returns(true); + auth.response = { refreshToken: "test_refresh_token" }; + + const refreshedResponse = { accessToken: "refreshed_token", refreshExpiresIn: 3600 }; + authMakeRequestStub.returns(refreshedResponse); + + const result = await auth.refreshToken(); + + sinon.assert.notCalled(authAuthenticateStub); + assert.deepEqual(result, refreshedResponse); + }); + + it("Token exists, can be renewed, but refresh returns a session-capped token: falls through to authenticate.", async () => { + authTokenExistsStub.returns(true); + authTokenCanBeRenewedStub.returns(true); + auth.response = { refreshToken: "test_refresh_token" }; + + const cappedResponse = { accessToken: "capped_refreshed_token", refreshExpiresIn: 5 }; + authMakeRequestStub.returns(cappedResponse); + authAuthenticateStub.returns({ accessToken: "reauthed_token" }); + + const result = await auth.refreshToken(); + + sinon.assert.calledOnce(authMakeRequestStub); + sinon.assert.calledOnce(authAuthenticateStub); + assert.deepEqual(result, { accessToken: "reauthed_token" }); + }); + it("Token exists but can't be renewed.", async () => { authTokenExistsStub.returns(true); authTokenCanBeRenewedStub.returns(false); From a405df8f13f456f971133a758ce81157dab8d645 Mon Sep 17 00:00:00 2001 From: Pavel Loparev Date: Fri, 17 Jul 2026 11:23:03 +0300 Subject: [PATCH 2/2] AUT-1496 Address review: offline tokens and misleading fallback log isSessionCapped() treated refreshExpiresIn: 0 (Keycloak's sentinel for offline-type tokens, which don't expire on the normal refresh TTL) as session-capped, forcing an unnecessary full re-authenticate. Guard with refreshExpiresIn > 0, which also keeps missing/non-numeric values correctly non-capped without a separate validation guard. Also decouple the re-auth fallback log line from "Can't refresh" wording since it's shared with the session-capped path, where the refresh call actually succeeded. Co-Authored-By: Claude Sonnet 5 --- api/auth/index.ts | 4 ++-- test/auth.spec.ts | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/api/auth/index.ts b/api/auth/index.ts index b254f69..67e95a9 100644 --- a/api/auth/index.ts +++ b/api/auth/index.ts @@ -56,13 +56,13 @@ export class SmartlingAuthApi extends SmartlingBaseApi implements AccessTokenPro this.logger.debug(`Refreshed token has a session-capped lifetime (${refreshedToken.refreshExpiresIn}s); re-authenticating.`); } - this.logger.debug(`Can't refresh, doing re-auth with: ${JSON.stringify(this.response, SmartlingBaseApi.sensitiveReplacer)}`); + this.logger.debug(`Falling back to re-auth with: ${JSON.stringify(this.response, SmartlingBaseApi.sensitiveReplacer)}`); return await this.authenticate(); } isSessionCapped(response: AccessTokenDto): boolean { - return response.refreshExpiresIn < this.ttlCorrectionSec; + return response.refreshExpiresIn > 0 && response.refreshExpiresIn < this.ttlCorrectionSec; } resetRequestTimeStamp(): void { diff --git a/test/auth.spec.ts b/test/auth.spec.ts index b35752f..18aa1a3 100644 --- a/test/auth.spec.ts +++ b/test/auth.spec.ts @@ -121,6 +121,20 @@ describe("Auth class tests.", () => { assert.deepEqual(result, { accessToken: "reauthed_token" }); }); + it("Token exists, can be renewed, and refresh returns an offline-type token (refreshExpiresIn: 0): treated as non-capped, not re-authenticated.", async () => { + authTokenExistsStub.returns(true); + authTokenCanBeRenewedStub.returns(true); + auth.response = { refreshToken: "test_refresh_token" }; + + const offlineResponse = { accessToken: "offline_refreshed_token", refreshExpiresIn: 0 }; + authMakeRequestStub.returns(offlineResponse); + + const result = await auth.refreshToken(); + + sinon.assert.notCalled(authAuthenticateStub); + assert.deepEqual(result, offlineResponse); + }); + it("Token exists but can't be renewed.", async () => { authTokenExistsStub.returns(true); authTokenCanBeRenewedStub.returns(false);