Skip to content

AUT-1496 Re-authenticate instead of refresh-looping on session-capped token#149

Merged
PavelLoparev merged 2 commits into
masterfrom
AUT-1496-session-cap-reauth
Jul 17, 2026
Merged

AUT-1496 Re-authenticate instead of refresh-looping on session-capped token#149
PavelLoparev merged 2 commits into
masterfrom
AUT-1496-session-cap-reauth

Conversation

@PavelLoparev

@PavelLoparev PavelLoparev commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • SmartlingAuthApi.refreshToken() only fell back to authenticate() when the refresh call threw, never when it succeeded but returned a session-capped token (Keycloak caps a refreshed token's lifetime to whatever remains of the parent SSO session as it nears its 12h max, so refreshed tokens returned right before that cutoff can be seconds long).
  • Adds isSessionCapped(), checked after a successful refresh: if the refreshed token's refreshExpiresIn is under the existing freshness buffer (ttlCorrectionSec, 10s), discard it and fall through to a full authenticate() instead of returning an unusable token.
  • Same bug class already fixed in authentication-api-sdk#17 (AUT-1473) and java-api-sdk#137 (AUT-1474); this PR is the Node SDK counterpart per AUT-1496.
  • No new public API surface — behavior is always-on, matching AUT-1473's constraint.
  • Version bumped 2.35.0 → 2.35.1.

Test plan

  • Unit tests for isSessionCapped boundary behavior via refreshToken() (capped refresh falls through to authenticate(); normal refresh returned as-is)
  • Full suite passes (284 passing)
  • Lint clean (npm run pretest)

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

… 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 <noreply@anthropic.com>

@maescomua maescomua left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review-loop pass (auth-reliability + test-quality personas). Fix is correct and safe to merge — leaving two non-blocking suggestions inline.

Comment thread api/auth/index.ts
isSessionCapped(response: AccessTokenDto): boolean {
return response.refreshExpiresIn < this.ttlCorrectionSec;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): isSessionCapped fails open if refreshExpiresIn is ever missing or non-numeric on a successful refresh response — undefined < this.ttlCorrectionSec is false, so a malformed response is treated as "not capped" and returned as-is.

In practice this self-heals: the same missing field makes tokenCanBeRenewed()'s arithmetic evaluate to NaN, so the very next refreshToken() call is forced into a full authenticate() anyway. And there's no runtime DTO validation anywhere else in this file (makeRequest returns any from a bare JSON.parse), so adding a guard here would be the only validated field in an otherwise-unvalidated contract.

Flagging as a judgment call rather than a fix: is the one-cycle exposure worth a typeof/Number.isFinite guard, or is matching this file's existing no-validation convention preferable? Either answer seems defensible.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refreshExpiresIn - I learned yesterday that it can be 0 for offline token type. So probably we should address this case too (because MCP server will use offline tokens).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by combining with dimitrystd's offline-token point below: refreshExpiresIn > 0 && refreshExpiresIn < this.ttlCorrectionSec. The > 0 guard also makes undefined/NaN safely non-capped (same as before), so no separate typeof/Number.isFinite check needed. In a405df8.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed - Keycloak sets refresh_expires_in: 0 for offline-type tokens as a sentinel (governed by Offline Session Idle/Max, not the normal refresh TTL), not "already expired". Fixed with a refreshExpiresIn > 0 guard in isSessionCapped, covered by a new test (offline-type refresh returns as non-capped). In a405df8.

Note: tokenCanBeRenewed() has the same refreshExpiresIn-arithmetic issue for the currently held token (computes requestTimestamp + 0 - ttlCorrectionSec, always in the past) - meaning offline-token clients today never even reach the refresh branch, they always full-authenticate(). That predates this PR and isn't in AUT-1496's scope, but worth a follow-up ticket if the MCP server work depends on offline tokens actually being refreshed rather than always re-authenticated.

Comment thread api/auth/index.ts
);

if (!this.isSessionCapped(refreshedToken)) {
return refreshedToken;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (non-blocking): this debug line still reads Can't refresh, doing re-auth with: on the new session-capped fallthrough path — but on that path the refresh call succeeded; it was discarded for being session-capped, not because refresh failed. During an incident, seeing Refreshed token has a session-capped lifetime (5s); re-authenticating. immediately followed by Can't refresh, doing re-auth with: {...} is contradictory and could send an on-call engineer down the wrong path (backend outage vs. expected Keycloak session-cap behavior).

Suggested change
return refreshedToken;
this.logger.debug(`Falling back to re-auth with: ${JSON.stringify(this.response, SmartlingBaseApi.sensitiveReplacer)}`);

(Wording is just a suggestion — the point is decoupling this log from the specific "can't refresh" claim so it reads correctly on both the throw-based and session-capped fallback paths.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed - changed to "Falling back to re-auth with:" so it reads correctly on both the throw-based and session-capped fallback paths. In a405df8.

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 <noreply@anthropic.com>
@PavelLoparev
PavelLoparev merged commit 07cdaf7 into master Jul 17, 2026
@PavelLoparev
PavelLoparev deleted the AUT-1496-session-cap-reauth branch July 17, 2026 08:26
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.

3 participants