Skip to content

fix: align scheduled refresh buffer with isExpiring buffer#80

Merged
nicknisi merged 2 commits into
workos:mainfrom
GCdM:fix/token-refresh-buffer-mismatch
May 8, 2026
Merged

fix: align scheduled refresh buffer with isExpiring buffer#80
nicknisi merged 2 commits into
workos:mainfrom
GCdM:fix/token-refresh-buffer-mismatch

Conversation

@GCdM
Copy link
Copy Markdown
Contributor

@GCdM GCdM commented May 8, 2026

Minor bug with the scheduled token refresh mechanism for tokens with a TTL of 5 minutes or less.

tl;dr - For tokens of 5min or less, the buffer to refresh drops to 30s, but the scheduler still schedules to (TTL - 60s). This results in a no-op scheduled refresh, as at 1min to TTL isExpiring returns false.

Problem

tokenStore.ts has two pieces of logic that both reason about "how close to expiry should we refresh," and they disagree.

getRefreshDelay always uses TOKEN_EXPIRY_BUFFER_SECONDS = 60:

private getRefreshDelay(timeUntilExpiry: number) {
  if (timeUntilExpiry <= TOKEN_EXPIRY_BUFFER_SECONDS) return 0;
  const idealDelay = (timeUntilExpiry - TOKEN_EXPIRY_BUFFER_SECONDS) * 1000;
  // ...
}

parseToken uses a different buffer for short-lived tokens:

let bufferSeconds = TOKEN_EXPIRY_BUFFER_SECONDS; // 60
const totalTokenLifetime = payload.exp - (payload.iat || now);
if (totalTokenLifetime <= 300) {
  bufferSeconds = 30;                            // ← only here
}
const isExpiring = payload.exp < now + bufferSeconds;

The scheduled timer's callback is getAccessTokenSilently, which calls parseToken and early-returns without refreshing or rescheduling when isExpiring === false.

For a 5-minute token issued at T=0:

t Event
T+0 Constructor calls scheduleRefresh(300). Timer set for T+240s (60s buffer).
T+240 Timer fires → parseToken says 60s left, but its buffer is 30s → isExpiring = false → early-return. No refresh, no new timer.
T+300 Access token expires. Nothing watches.
T+300+ Requests carry expired JWT → 401, until a focus / visibilitychange / online / pageshow event triggers getAccessTokenSilently.

Solution

Route both buffers through one source of truth.

  • New getExpiryBuffer(totalTokenLifetime) returns 30s for tokens with lifetime ≤ 300s, 60s otherwise.
  • parseToken uses it and now also returns totalTokenLifetime.
  • scheduleRefresh / getRefreshDelay accept the full tokenData and apply the same helper.
  • isExpiring switches from < to <= so a token whose remaining lifetime lands exactly on the buffer boundary is treated as expiring - the schedule fires at (timeUntilExpiry - buffer) * 1000 ms, which lands timeUntilExpiry
    exactly back at buffer. Strict < would no-op at that boundary.

After the change, a 5-minute token schedules its first refresh at T+270s, and the timer's callback call

Tests

One new behavioral test in tokenStore.spec.ts:

  • Installs a 5-minute token via the cookie path so the constructor schedules a refresh without invokingount starts at 0).
  • Advances fake timers past the scheduled fire time.
  • Asserts refreshAccessTokenAction was called exactly once.

Verified RED on the unfixed code (expected 1 times, got 0 - the schedule fires but the callback no-op

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 8, 2026

Greptile Summary

This PR fixes a scheduling bug where short-lived tokens (TTL ≤ 5 minutes) would have their refresh timer fire when isExpiring was still false, causing the callback to silently no-op and leaving the token unmonitored until an external event. The fix extracts a getExpiryBuffer helper as a single source of truth and threads totalTokenLifetime through the refresh pipeline so both the scheduler and isExpiring use the same buffer value.

  • getExpiryBuffer(totalTokenLifetime) replaces two independently maintained buffer constants, ensuring parseToken and getRefreshDelay can never disagree on the buffer size.
  • isExpiring changes from < to <= to handle the exact boundary case: the timer fires precisely when timeUntilExpiry === bufferSeconds, and strict < would no-op at that point even with aligned buffers.
  • A new behavioral regression test exercises the full cycle via the cookie path to confirm refreshAccessTokenAction is called exactly once after the scheduler fires for a 5-minute token.

Confidence Score: 4/5

Safe to merge; the core bug is correctly fixed and the new test clearly demonstrates the before/after behavior.

The buffer alignment and <= boundary fix are correct and well-tested. One residual gap: if setTimeout fires a few milliseconds early (normal jitter), getAccessTokenSilently can still take the isExpiring=false early-return path without rescheduling, leaving the token unmonitored. This is far less likely than before, but the path is not fully guarded.

The getAccessTokenSilently early-return path around lines 262-263 of tokenStore.ts deserves a second look for the missing reschedule case.

Important Files Changed

Filename Overview
src/client/tokenStore.ts Core fix: extracts getExpiryBuffer as single source of truth, threads totalTokenLifetime through scheduleRefresh/getRefreshDelay, and changes isExpiring from strict < to <= to handle the exact boundary case; one minor gap remains on the no-op early-return path in getAccessTokenSilently
src/client/tokenStore.spec.ts Adds a precise behavioral regression test that verifies a 5-minute token triggers refreshAccessTokenAction exactly once after the scheduler fires; test correctly uses the cookie path to exercise the constructor-side schedule
src/client/useAccessToken.spec.tsx Mechanical update to the mock tokenData shape to include the new totalTokenLifetime field; no logic changes

Sequence Diagram

sequenceDiagram
    participant C as Constructor
    participant S as scheduleRefresh
    participant T as setTimeout
    participant G as getAccessTokenSilently
    participant R as refreshTokenSilently

    Note over C: Token issued (TTL=300s, iat=T0)
    C->>S: "scheduleRefresh({timeUntilExpiry:300, totalTokenLifetime:300})"
    Note over S: getExpiryBuffer(300) -> 30s, delay=270,000ms
    S->>T: setTimeout(270,000ms)
    Note over T: T+270s - timer fires
    T->>G: getAccessTokenSilently()
    Note over G: timeUntilExpiry=30, buffer=30
    Note over G: isExpiring = (exp <= now+30) -> true
    G->>R: refreshTokenSilently()
    R-->>S: scheduleRefresh(newTokenData)
    Note over S: New token TTL=3600s, next refresh ~3270s
Loading

Comments Outside Diff (1)

  1. src/client/tokenStore.ts, line 262-264 (link)

    P2 Unmonitored token on early-return after timer drift

    When the scheduled timer fires and getAccessTokenSilently is called, if the timer fires even slightly early (a few milliseconds of jitter is normal for setTimeout), timeUntilExpiry will be bufferSeconds + ε, making isExpiring = false. The function returns on line 263 without rescheduling a new timeout — the token is then unmonitored until an external event (focus, online, visibilitychange) triggers another call. This fix greatly reduces the window where this occurs (buffers are now aligned), but the gap isn't fully closed. Adding a this.scheduleRefresh(tokenData) call on the early-return path of getAccessTokenSilently would make the schedule self-healing regardless of jitter.

Reviews (1): Last reviewed commit: "implement short token expiry buffer" | Re-trigger Greptile

Copy link
Copy Markdown
Member

@nicknisi nicknisi left a comment

Choose a reason for hiding this comment

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

Good catch, thanks!

@nicknisi nicknisi merged commit de3ea6e into workos:main May 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants