Skip to content

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

Open
PavelLoparev wants to merge 1 commit into
masterfrom
AUT-1496-session-cap-reauth
Open

AUT-1496 Re-authenticate instead of refresh-looping on session-capped token#149
PavelLoparev wants to merge 1 commit 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).

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.)

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