Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-refresh-token-loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@asgardeo/browser': patch
'@asgardeo/react': patch
---

Fix infinite loop of refresh grant requests when the refresh token is expired or revoked
16 changes: 16 additions & 0 deletions packages/browser/src/__legacy__/helpers/authentication-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,19 @@ export class AuthenticationHelper<T extends MainThreadClientConfig | WebWorkerCl
protected _spaHelper: SPAHelper<T>;
protected _instanceId: number;
protected _isTokenRefreshing: boolean;
protected _hasRefreshFailed: boolean;

public constructor(authClient: AsgardeoAuthClient<T>, spaHelper: SPAHelper<T>) {
this._authenticationClient = authClient;
this._storageManager = this._authenticationClient.getStorageManager();
this._spaHelper = spaHelper;
this._instanceId = this._authenticationClient.getInstanceId();
this._isTokenRefreshing = false;
this._hasRefreshFailed = false;
}

public hasRefreshFailed(): boolean {
return this._hasRefreshFailed;
}

public enableHttpHandler(httpClient: HttpClient): void {
Expand Down Expand Up @@ -180,6 +186,7 @@ export class AuthenticationHelper<T extends MainThreadClientConfig | WebWorkerCl
if (customGrantConfig) {
await this.exchangeToken(customGrantConfig, enableRetrievingSignOutURLFromSession);
}
this._hasRefreshFailed = false;
this._spaHelper.refreshAccessTokenAutomatically(this);

return this._authenticationClient.getUser();
Expand Down Expand Up @@ -481,6 +488,7 @@ export class AuthenticationHelper<T extends MainThreadClientConfig | WebWorkerCl
return this._authenticationClient
.requestAccessToken(authorizationCode, sessionState ?? '', state ?? '', undefined, tokenRequestConfig)
.then(async () => {
this._hasRefreshFailed = false;
// Disable this temporarily
/* if (config.storage === Storage.BrowserMemory) {
SPAUtils.setSignOutURL(await _authenticationClient.getSignOutUrl());
Expand Down Expand Up @@ -717,6 +725,13 @@ export class AuthenticationHelper<T extends MainThreadClientConfig | WebWorkerCl
return this._authenticationClient.isSignedIn();
}

// Skip the refresh attempt if a previous one already failed with this session's
// tokens — avoids an infinite loop of refresh grant requests when the refresh
// token is expired or revoked (flag is cleared on fresh sign-in or successful refresh).
if (this._hasRefreshFailed) {
return false;
}

// Token may be expired — attempt a silent refresh before giving up.
try {
this._isTokenRefreshing = true;
Expand All @@ -726,6 +741,7 @@ export class AuthenticationHelper<T extends MainThreadClientConfig | WebWorkerCl
return true;
} catch {
this._isTokenRefreshing = false;
this._hasRefreshFailed = true;

return false;
}
Expand Down
1 change: 1 addition & 0 deletions packages/browser/src/__legacy__/helpers/spa-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class SPAHelper<T extends MainThreadClientConfig | WebWorkerClientConfig>

if (timeUntilRefresh <= 0) {
if (this._isTokenRefreshLoading) return;
if (authenticationHelper.hasRefreshFailed()) return;
Comment thread
ShanChathusanda93 marked this conversation as resolved.

this._isTokenRefreshLoading = true;
try {
Expand Down
17 changes: 17 additions & 0 deletions packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,23 @@ const AsgardeoProvider: FC<PropsWithChildren<AsgardeoProviderProps>> = ({
};
}, [asgardeo]);

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

const handleRefreshTokenError = (event: MessageEvent): void => {
if (event?.data?.type === 'refresh-access-token-error') {
setIsSignedInSync(false);
Comment thread
ShanChathusanda93 marked this conversation as resolved.
}
Comment thread
ShanChathusanda93 marked this conversation as resolved.
};

window.addEventListener('message', handleRefreshTokenError);

return (): void => {
window.removeEventListener('message', handleRefreshTokenError);
};
}, []);

useEffect(() => {
(async (): Promise<void> => {
try {
Expand Down
Loading