Skip to content

Fix infinite loop of refresh grant requests when the refresh token is expired or revoked#531

Merged
ShanChathusanda93 merged 1 commit into
asgardeo:mainfrom
ShanChathusanda93:refresh-call-issue-branch
Jun 23, 2026
Merged

Fix infinite loop of refresh grant requests when the refresh token is expired or revoked#531
ShanChathusanda93 merged 1 commit into
asgardeo:mainfrom
ShanChathusanda93:refresh-call-issue-branch

Conversation

@ShanChathusanda93

@ShanChathusanda93 ShanChathusanda93 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Purpose

$subject

Problem

Root cause:

isSignedIn() now attempts a refresh grant whenever the access token is expired. The existing _isTokenRefreshing flag only guards against concurrent refresh attempts — it resets to false after each failure. This meant every subsequent call to isSignedIn() (from _validateMethod, navigation guards, AsgardeoProvider's polling interval, etc.) would fire another /token request, creating a rapid-fire loop with no exit condition.

There were two additional contributing factors:

  1. AsgardeoProvider's React context (isSignedInSync) was never updated when the refresh failed, so app hooks continued to call auth methods believing the user was still signed in.
  2. scheduleAutoRefresh() in AsgardeoProvider was calling refreshAccessTokenAutomatically() which bypasses isSignedIn() entirely and calls refreshAccessToken() directly — so the loop also fired through the periodic refresh path.

Changes

packages/browser/src/__legacy__/helpers/authentication-helper.ts

  • Added _hasRefreshFailed: boolean flag (initialized false) to track whether a refresh attempt has already failed for the current session's tokens.
  • After the first refresh failure in isSignedIn(), sets _hasRefreshFailed = true — all subsequent isSignedIn() calls return false immediately without hitting the network.
  • Resets _hasRefreshFailed = false in two places:
    • refreshAccessToken() on success — covers the HTTP interceptor and SPAHelper timer paths.
    • requestAccessToken() callback — covers fresh sign-in via auth code exchange.
  • Added hasRefreshFailed() public getter so SPAHelper can read the flag.

packages/browser/src/__legacy__/helpers/spa-helper.ts

  • Added authenticationHelper.hasRefreshFailed() check in the timeUntilRefresh <= 0 immediate-refresh path inside refreshAccessTokenAutomatically(). Prevents the SPAHelper from firing a redundant refresh attempt after isSignedIn() has already marked the refresh token as failed.

packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx

  • Added a useEffect listener for the refresh-access-token-error window message (posted by refreshAccessToken() on failure). When received, sets isSignedInSync = false so the React context correctly reflects the unauthenticated state — stopping app components and hooks from continuing to call auth methods.

Behavior after fix

Scenario Before After
Refresh token expired, isSignedIn() called repeatedly Refresh grant fired on every call Refresh grant fired once; all subsequent calls return false immediately
scheduleAutoRefresh() called after a failed refresh Fires another refresh grant Blocked by hasRefreshFailed() check in SPAHelper
React hooks after refresh failure Keep calling getIdToken() (context still shows signed in) isSignedInSync = false propagated via window message; hooks stop
Fresh sign-in after token expiry _hasRefreshFailed never reset Reset on requestAccessToken success; normal refresh behavior resumes

Related Issues

  • N/A

Related PRs

  • N/A

Checklist

  • Followed the CONTRIBUTING guidelines.
  • Manual test round performed and verified.
  • Documentation provided. (Add links if there are any)
  • Unit tests provided. (Add links if there are any)

Security checks

Summary by CodeRabbit

  • Bug Fixes
    • Fixed an infinite loop of refresh token requests that occurred when the refresh token was expired or revoked. The authentication system now properly handles failed refresh attempts and updates the signed-in state accordingly.

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a _hasRefreshFailed boolean flag in AuthenticationHelper to track refresh token failure state. Guards in isSignedIn() and refreshAccessTokenAutomatically() short-circuit on that flag to prevent infinite retry loops. The React AsgardeoProvider gains a window message listener that sets isSignedInSync to false on refresh-access-token-error events.

Changes

Refresh Token Loop Fix

Layer / File(s) Summary
_hasRefreshFailed flag and isSignedIn guard
packages/browser/src/__legacy__/helpers/authentication-helper.ts
Adds _hasRefreshFailed boolean field and hasRefreshFailed() public accessor to AuthenticationHelper. isSignedIn() returns false immediately when the flag is set; sets the flag to true on catch and resets it to false after successful refreshAccessToken() (custom-grant path) and requestAccessToken() continuations.
SPAHelper automatic refresh guard
packages/browser/src/__legacy__/helpers/spa-helper.ts
Adds authenticationHelper.hasRefreshFailed() as an additional early-exit condition inside refreshAccessTokenAutomatically when _isTokenRefreshLoading is already true.
React provider error signal
packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
Adds a mount-scoped useEffect that registers a window message listener; sets isSignedInSync to false when receiving a refresh-access-token-error typed message, and removes the listener on cleanup.
Changeset
.changeset/fix-refresh-token-loop.md
Adds patch-level release entries for @asgardeo/browser and @asgardeo/react describing the infinite refresh-loop fix.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

Possibly related PRs

  • asgardeo/javascript#528: Both PRs modify isSignedIn() and the refresh flow in authentication-helper.ts; PR #528 adds silent refresh on expired access tokens, which is the scenario where this PR's infinite-loop guard becomes critical.

Suggested reviewers

  • brionmario
  • thiva-k

Poem

🐇 Hop, hop, the token spun,
Refreshing loops that never were done!
A flag was planted, _hasRefreshFailed,
No more forever the rabbit was jailed.
One false, one catch, and the loop is slain —
The burrow is safe and signed in again! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main fix—preventing infinite loop of refresh grant requests when the refresh token is expired or revoked.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Tools execution failed with the following error:

Failed to run tools: Ping-pong health check failed


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.

@asgardeo-github-bot

Copy link
Copy Markdown

🦋 Changeset detected

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
.changeset/fix-refresh-token-loop.md (1)

7-7: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove stray character on line 7.

The changeset entry contains an erroneous 7 character on line 7, which will cause parsing issues with the Changesets tool. The description should end cleanly after the text on line 6.

🛠️ Proposed fix
 Fix infinite loop of refresh grant requests when the refresh token is expired or revoked
-7
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/fix-refresh-token-loop.md at line 7, Remove the stray `7`
character that appears on line 7 of the changeset file. This character is
extraneous and will cause parsing issues with the Changesets tool. Delete this
character so the changeset entry ends cleanly after the description text on line
6.
packages/browser/src/__legacy__/helpers/authentication-helper.ts (2)

146-148: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Clear the latch when a custom grant creates a new session.

The flag is cleared after auth-code sign-in and refresh success, but a direct exchangeToken() with returnsSession can install fresh tokens and still inherit _hasRefreshFailed = true, causing auto-refresh to remain blocked for the new session.

Suggested fix
 if (config.returnsSession) {
+  this._hasRefreshFailed = false;
   this._spaHelper.refreshAccessTokenAutomatically(this);
 
   return this._authenticationClient.getUser();

Also applies to: 189-189

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/browser/src/__legacy__/helpers/authentication-helper.ts` around
lines 146 - 148, The _hasRefreshFailed flag is not being cleared when a custom
grant with returnsSession creates a new session, causing auto-refresh to remain
blocked even though fresh tokens have been installed. In the conditional block
where config.returnsSession is true (around the call to
this._spaHelper.refreshAccessTokenAutomatically), clear the _hasRefreshFailed
flag before calling refreshAccessTokenAutomatically to reset the latch for the
new session and allow automatic token refresh to function properly.

180-199: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Move the refresh-failure latch into the refresh-grant path.

Line 744 only latches failures when isSignedIn() initiated the refresh. Auto-refresh and request-retry callers invoke refreshAccessToken() directly, so those failures can still leave _hasRefreshFailed false. Split refreshAccessToken() so only the actual refresh-grant failure sets the latch; custom-grant replay failures after a successful refresh should not poison the refresh token state.

Suggested structure
 public async refreshAccessToken(
   enableRetrievingSignOutURLFromSession?: (config: SPACustomGrantConfig) => void,
 ): Promise<User> {
+  if (this._hasRefreshFailed) {
+    throw new AsgardeoAuthException(
+      'SPA-AUTH_HELPER-RAT2-SE01',
+      'Refresh token request skipped.',
+      'A previous refresh token request failed for the current session.',
+    );
+  }
+
   try {
     await this._authenticationClient.refreshAccessToken();
-    const customGrantConfig = await this.getCustomGrantConfigData();
-    if (customGrantConfig) {
-      await this.exchangeToken(customGrantConfig, enableRetrievingSignOutURLFromSession);
-    }
     this._hasRefreshFailed = false;
-    this._spaHelper.refreshAccessTokenAutomatically(this);
-
-    return this._authenticationClient.getUser();
   } catch (error) {
+    this._hasRefreshFailed = true;
     const refreshTokenError: Message<string> = {
       type: REFRESH_ACCESS_TOKEN_ERR0R,
     };
 
     window.postMessage(refreshTokenError);
     return Promise.reject(error);
   }
+
+  try {
+    const customGrantConfig = await this.getCustomGrantConfigData();
+    if (customGrantConfig) {
+      await this.exchangeToken(customGrantConfig, enableRetrievingSignOutURLFromSession);
+    }
+    this._spaHelper.refreshAccessTokenAutomatically(this);
+
+    return this._authenticationClient.getUser();
+  } catch (error) {
+    return Promise.reject(error);
+  }
 }
 
 ...
 
 } catch {
   this._isTokenRefreshing = false;
-  this._hasRefreshFailed = true;
 
   return false;
 }

Also applies to: 736-744

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/browser/src/__legacy__/helpers/authentication-helper.ts` around
lines 180 - 199, The refresh-failure latch _hasRefreshFailed is currently set to
false after the entire refresh operation completes, including the custom-grant
exchange. Move the line that sets _hasRefreshFailed to false immediately after
the refreshAccessToken() call succeeds (before the custom grant exchange logic),
so that only actual refresh-grant failures set the latch. This ensures that
failures in the exchangeToken() call do not poison the refresh token state when
the underlying token refresh itself was successful. The custom-grant replay
errors should be handled separately without affecting the refresh token state
tracking.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/browser/src/__legacy__/helpers/spa-helper.ts`:
- Around line 55-57: The hasRefreshFailed() guard check on line 57 only protects
the immediate refresh path when timeUntilRefresh <= 0. A scheduled timer
callback set up in the code around lines 68-74 can still execute and attempt to
refresh the token even after a refresh failure has been recorded. Add the same
authenticationHelper.hasRefreshFailed() guard check inside the scheduled refresh
callback function to prevent retry attempts when a refresh has already failed,
mirroring the protection already present in the immediate refresh path.

In `@packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx`:
- Around line 395-397: The handleRefreshTokenError function currently filters
messages only by the event type without validating the message source, origin,
or instanceId, which allows other frames or AsgardeoProvider instances to
trigger the signed-out state. Add validation checks in handleRefreshTokenError
to verify that the message source is from the current window, the origin matches
the expected origin, and the instanceId in the event data matches the current
provider instance before calling setIsSignedInSync(false).
- Around line 396-398: When the 'refresh-access-token-error' event is triggered,
the code currently only sets isSignedInSync to false but leaves the user,
profile, and organization state cached, allowing stale authenticated user data
to be rendered. In addition to setting isSignedInSync to false, also clear the
user, profile, and organization state within the same event handler block to
ensure all cached user context is properly cleared when the session becomes
invalid.

---

Outside diff comments:
In @.changeset/fix-refresh-token-loop.md:
- Line 7: Remove the stray `7` character that appears on line 7 of the changeset
file. This character is extraneous and will cause parsing issues with the
Changesets tool. Delete this character so the changeset entry ends cleanly after
the description text on line 6.

In `@packages/browser/src/__legacy__/helpers/authentication-helper.ts`:
- Around line 146-148: The _hasRefreshFailed flag is not being cleared when a
custom grant with returnsSession creates a new session, causing auto-refresh to
remain blocked even though fresh tokens have been installed. In the conditional
block where config.returnsSession is true (around the call to
this._spaHelper.refreshAccessTokenAutomatically), clear the _hasRefreshFailed
flag before calling refreshAccessTokenAutomatically to reset the latch for the
new session and allow automatic token refresh to function properly.
- Around line 180-199: The refresh-failure latch _hasRefreshFailed is currently
set to false after the entire refresh operation completes, including the
custom-grant exchange. Move the line that sets _hasRefreshFailed to false
immediately after the refreshAccessToken() call succeeds (before the custom
grant exchange logic), so that only actual refresh-grant failures set the latch.
This ensures that failures in the exchangeToken() call do not poison the refresh
token state when the underlying token refresh itself was successful. The
custom-grant replay errors should be handled separately without affecting the
refresh token state tracking.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f440e1cc-e726-4033-b192-a69c589c6f02

📥 Commits

Reviewing files that changed from the base of the PR and between 66838b8 and b8f71d6.

📒 Files selected for processing (4)
  • .changeset/fix-refresh-token-loop.md
  • packages/browser/src/__legacy__/helpers/authentication-helper.ts
  • packages/browser/src/__legacy__/helpers/spa-helper.ts
  • packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx

Comment thread packages/browser/src/__legacy__/helpers/spa-helper.ts
Comment thread packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
Comment thread packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
// When the refresh token is expired or revoked, the browser SDK posts a
// 'refresh-access-token-error' message. Sync the React context so components
// stop calling auth methods and the user is redirected to sign in again.
useEffect(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We may need to port this fix to other SDKs as well. @coderabbitai Create an issue in https://github.com/asgardeo/asgardeo to track this

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

gh issue create \
  --repo "asgardeo/asgardeo" \
  --assignee "brionmario" \
  --title "Port infinite refresh-token loop fix to other SDKs" \
  --body "## Summary

When a refresh token is expired or revoked, certain Asgardeo SDKs enter an infinite loop of refresh-grant requests. A fix was implemented in the **JavaScript SDK** ([`@asgardeo/browser`](https://github.com/asgardeo/javascript/tree/main/packages/browser) and [`@asgardeo/react`](https://github.com/asgardeo/javascript/tree/main/packages/react)) via [asgardeo/javascript#531](https://github.com/asgardeo/javascript/pull/531). The same fix needs to be ported to all other Asgardeo SDKs.

## Root Cause

The \`isSignedIn()\` method attempts a refresh grant when the access token expires. The \`_isTokenRefreshing\` guard flag resets to \`false\` after each failure, causing every subsequent call to \`isSignedIn()\` (from navigation guards, polling intervals, \`_validateMethod\`, etc.) to fire new \`/token\` requests. Two additional factors contributed:
- The React context's \`isSignedInSync\` was never updated on refresh failure.
- \`scheduleAutoRefresh()\` called \`refreshAccessTokenAutomatically()\`, bypassing the guard mechanism.

## Fix Applied in JavaScript SDK

1. **authentication-helper.ts** – Added a \`_hasRefreshFailed\` boolean flag. After the first refresh failure in \`isSignedIn()\`, the flag is set to \`true\` and all subsequent calls return \`false\` immediately (no network requests). The flag resets to \`false\` on successful refresh or fresh sign-in via auth code exchange.
2. **spa-helper.ts** – Added a \`hasRefreshFailed()\` check in the immediate-refresh path of \`refreshAccessTokenAutomatically()\` to stop redundant attempts after \`isSignedIn()\` has marked the token as failed.
3. **AsgardeoProvider.tsx (React)** – Added a \`useEffect\` listener for the \`refresh-access-token-error\` window message. When received, sets \`isSignedInSync = false\` so React components stop treating the user as signed in.

## Action Required

Port equivalent logic to all other Asgardeo SDK packages, ensuring:
- [ ] A failure-tracking flag (or equivalent mechanism) prevents repeated refresh-grant requests after the first failure.
- [ ] The unauthenticated state is propagated correctly to framework-level context/providers.
- [ ] Normal refresh behavior resumes after a fresh sign-in.

## References

- Fix PR: [asgardeo/javascript#531](https://github.com/asgardeo/javascript/pull/531)
- Originating comment: https://github.com/asgardeo/javascript/pull/531#discussion_r3460381234
- Requested by: `@brionmario`
"

Length of output: 3035


🏁 Script executed:

#!/bin/bash
set -euo pipefail

gh issue create \
  --repo "asgardeo/asgardeo" \
  --title "Port infinite refresh-token loop fix to other SDKs" \
  --body "## Summary

When a refresh token is expired or revoked, certain Asgardeo SDKs enter an infinite loop of refresh-grant requests. A fix was implemented in the **JavaScript SDK** ([`@asgardeo/browser`](https://github.com/asgardeo/javascript/tree/main/packages/browser) and [`@asgardeo/react`](https://github.com/asgardeo/javascript/tree/main/packages/react)) via [asgardeo/javascript#531](https://github.com/asgardeo/javascript/pull/531). The same fix needs to be ported to all other Asgardeo SDKs.

## Root Cause

The \`isSignedIn()\` method attempts a refresh grant when the access token expires. The \`_isTokenRefreshing\` guard flag resets to \`false\` after each failure, causing every subsequent call to \`isSignedIn()\` (from navigation guards, polling intervals, \`_validateMethod\`, etc.) to fire new \`/token\` requests. Two additional factors contributed:
- The React context's \`isSignedInSync\` was never updated on refresh failure.
- \`scheduleAutoRefresh()\` called \`refreshAccessTokenAutomatically()\`, bypassing the guard mechanism.

## Fix Applied in JavaScript SDK

1. **authentication-helper.ts** – Added a \`_hasRefreshFailed\` boolean flag. After the first refresh failure in \`isSignedIn()\`, the flag is set to \`true\` and all subsequent calls return \`false\` immediately (no network requests). The flag resets to \`false\` on successful refresh or fresh sign-in via auth code exchange.
2. **spa-helper.ts** – Added a \`hasRefreshFailed()\` check in the immediate-refresh path of \`refreshAccessTokenAutomatically()\` to stop redundant attempts after \`isSignedIn()\` has marked the token as failed.
3. **AsgardeoProvider.tsx (React)** – Added a \`useEffect\` listener for the \`refresh-access-token-error\` window message. When received, sets \`isSignedInSync = false\` so React components stop treating the user as signed in.

## Action Required

Port equivalent logic to all other Asgardeo SDK packages, ensuring:
- [ ] A failure-tracking flag (or equivalent mechanism) prevents repeated refresh-grant requests after the first failure.
- [ ] The unauthenticated state is propagated correctly to framework-level context/providers.
- [ ] Normal refresh behavior resumes after a fresh sign-in.

## References

- Fix PR: [asgardeo/javascript#531](https://github.com/asgardeo/javascript/pull/531)
- Originating comment: https://github.com/asgardeo/javascript/pull/531#discussion_r3460381234
- Requested by: `@brionmario`
"

Length of output: 2778



@brionmario, the tracking issue has been created: asgardeo/asgardeo#6Port infinite refresh-token loop fix to other SDKs.

It includes the root cause, a summary of all three changes from this PR, and acceptance criteria for the port. Note that I wasn't able to auto-assign it due to integration permissions — you may want to assign it manually from the issue page.

@ShanChathusanda93 ShanChathusanda93 merged commit 0621de1 into asgardeo:main Jun 23, 2026
6 of 9 checks passed
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