Skip to content

feat(security): refresh the access token in background ahead of expiry (4.x) - #359

Open
smarcet wants to merge 2 commits into
v4.xfrom
feat/background-token-refresh-4x
Open

smarcet wants to merge 2 commits into
v4.xfrom
feat/background-token-refresh-4x

Conversation

@smarcet

@smarcet smarcet commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

ref: https://app.clickup.com/t/86bcfte4c

getAccessToken only refreshed the access token once it had expired. event-site calls it from withRealTimeUpdates processUpdates on every real time push, in every open tab, so every client whose token had expired refreshed on the same second. On 2026-10-08 that showed up on the IDP as 1-2 second spikes of up to 68 POST /oauth2/token requests right after summit-api published schedule changes, even with the access token lifetime jitter already in place.

Changes

  • _getAccessToken: a still valid token inside its last ACCESS_TOKEN_REFRESH_AHEAD_SECS (300s, capped to a quarter of the token lifetime) is returned right away and a background refresh is scheduled once per tab after a random delay in [0, ACCESS_TOKEN_REFRESH_SPREAD_MS) (30s). Expired tokens are still refreshed synchronously, as before.
  • The background refresh runs under the same cross tab lock as getAccessToken (extracted into withAccessTokenLock), re-reads authInfo and skips the call if another tab already stored a newer token.
  • It makes a single attempt (processRefreshToken takes a withRetry flag): retrying with backoff would hold the lock for every tab while the current token is still valid. Transient errors are only logged.
  • On a rejected refresh token, refreshAccessToken sets clearing_session_state, which makes initLogin skip the re-login. Nobody handles that error in the background, so the previous flag value is restored and the logout stays with the hard expiry path, which surfaces the error to the caller.
  • Only scheduled for the code flow with a refresh token; implicit flow and tokens without a refresh token are untouched.
  • The refreshed token is stored only if the stored session is still the one the background refresh started from (same refresh token and accessTokenUpdatedAt), so a logout (LOGOUT_USER) or a new login (onUserAuth) during the request is not undone.
  • A refresh token the IDP rejected is not tried again in background; the hard expiry path handles the logout. A new login brings a new refresh token, so background refreshes resume. Transient errors stay retryable.
  • setAccessTokenResolver keeps precedence: when a resolver is registered getAccessToken delegates to it and none of this runs. A background refresh scheduled before a resolver is registered is skipped.

Testing

  • New getAccessToken background refresh tests in methods.test.js (fake timers): outside the window, inside the window with the random delay, single refresh per tab, another tab already refreshed, hard expiry, transient failure without retry, rejected refresh token, short lifetime cap, no refresh token, implicit flow, rejected refresh token not retried, transient failure retried, logout and new login during the request, resolver registered after scheduling. 5 of them fail against the previous methods.js, and each review fix has a test that fails when that fix is removed.
  • yarn jest: 18 suites, 203 tests pass. yarn build OK on Node 18.15.0 (.nvmrc; node-sass does not build on Node 22).

getAccessToken only refreshed once the token had expired, so every caller
that asked for a token on the same shared event (event-site asks on every
real time push, in every open tab) refreshed on the same second, which
showed up as synchronized spikes on POST /oauth2/token.

A still valid token inside its last ACCESS_TOKEN_REFRESH_AHEAD_SECS (capped
to a quarter of its lifetime) is now returned right away and refreshed once
per tab after a random delay in [0, ACCESS_TOKEN_REFRESH_SPREAD_MS), under
the same cross tab lock. The refresh is skipped if another tab already
stored a newer token, makes a single attempt, and on a rejected refresh
token restores the session clearing flag so the hard expiry path keeps
handling the logout. Expired tokens are still refreshed synchronously.
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 36ce139b-6ed0-43bd-888b-4596352e8825

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@smarcet
smarcet requested a review from gcutrini October 9, 2026 12:14
@smarcet smarcet self-assigned this Oct 9, 2026
@smarcet
smarcet requested review from santipalenque and a balanced review from Copilot October 9, 2026 12:14

Copilot AI 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.

🟡 Changes recommended

A queued background refresh can bypass a subsequently registered access-token resolver.

1 open finding
What changed in this PR

Adds proactive, randomized access-token refresh to reduce IDP request spikes.

Changes:

  • Schedules near-expiry refreshes under a cross-tab lock.
  • Preserves synchronous expiry handling and resolver precedence.
  • Adds comprehensive background-refresh tests.
File Description
src/​components/​security/​methods.js Implements locked background token refresh.
src/​components/​security/​__tests__/​methods.test.js Tests refresh timing, failures, and edge cases.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +431 to +433
const _backgroundRefresh = async () => {
const authInfo = getAuthInfo();
if (!authInfo || !authInfo.accessToken || !authInfo.refreshToken) return;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 50f4584. _backgroundRefresh checks the shared resolver slot under the lock and returns without touching authInfo or calling the IDP when a resolver has been registered after the refresh was scheduled. I kept setAccessTokenResolver unchanged since the check covers the pending timer. Covered by "skips a pending background refresh once an access token resolver is registered".

- Store the refreshed token only if the stored session is still the one the
  background refresh started from (same refresh token and issue time), so a
  logout or a new login during the request is not undone.
- Stop scheduling background refreshes for a refresh token the IDP rejected;
  the hard expiry path handles the logout. Transient errors stay retryable.
- Skip a pending background refresh once an access token resolver is
  registered.
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.

2 participants