Description
getIdToken() and getAccessToken() go through AsgardeoSPAClient._validateMethod (packages/browser/src/__legacy__/clients/main-thread-client.ts), which calls isSignedIn(). isSignedIn() returns false the instant created_at + expires_in * 1000 <= Date.now(), without consulting the refresh token:
// packages/javascript ~L1893-1905
async isSignedIn(userId) {
const isAccessTokenAvailable = Boolean(await this.getAccessToken(userId));
const createdAt = (await this.storageManager.getSessionData(userId))?.created_at;
const expiresInString = (await this.storageManager.getSessionData(userId))?.expires_in;
if (!expiresInString) return false;
const expiresIn = parseInt(expiresInString, 10) * 1e3;
const currentTime = new Date().getTime();
const isAccessTokenValid = createdAt + expiresIn > currentTime;
return isAccessTokenAvailable && isAccessTokenValid;
}
_validateMethod then rejects with SPA-AUTH_CLIENT-VM-IV02 ("The user is not authenticated."). No attempt to call refreshAccessToken is made, even though a valid refresh token is sitting in storage.
#442 fixed the timer path (refreshAccessTokenAutomatically now immediately refreshes when the token is already expired). But the on-demand path — what every consumer hits when they call getIdToken() or getAccessToken() after the timer has missed its window — is unchanged. In production this leaves apps in a broken state any time periodicTokenRefresh's setTimeout doesn't fire in time: browser background-tab throttling, system sleep across the original expiry boundary, clock skew, or simply a tab idle past the access token TTL.
Most other OIDC SPA SDKs (oidc-client-ts, react-oidc-context, MSAL) refresh transparently from getAccessToken-style calls and only reject when refresh itself is impossible. Asgardeo's current behaviour forces every consumer to wrap getIdToken() with their own signInSilently / refreshAccessToken fallback to avoid surfacing a generic error to users.
Expected behaviour
When getIdToken() / getAccessToken() is called and the access token is expired but a refresh token exists:
- Call
refreshAccessToken transparently.
- Return the new token.
- Only reject if the refresh itself fails (e.g., the refresh token has been revoked / expired).
Equivalent option: make isSignedIn() treat "access token expired AND refresh token present" as still signed in, so _validateMethod doesn't reject — then have downstream callers (getAccessToken, the http layer) trigger the refresh before reading.
Steps to reproduce
- Sign in to any SPA using
@asgardeo/react.
- In DevTools Console, expire the access token in storage (keeps the refresh token intact):
const k = Object.keys(sessionStorage).find(k => k.startsWith('session_data-'));
const s = JSON.parse(sessionStorage.getItem(k));
s.created_at = Date.now();
s.expires_in = '10';
sessionStorage.setItem(k, JSON.stringify(s));
- Wait ~11s (the existing
periodicTokenRefresh timer was scheduled around the original expiry, so it does not reschedule based on the new in-storage values).
- Trigger any operation that calls
getIdToken() — e.g. an authenticated API call from the app.
Observed: getIdToken() rejects with:
{ code: 'SPA-AUTH_CLIENT-VM-IV02',
name: 'The user is not authenticated.',
message: 'The user must be authenticated first.' }
No network request to the token endpoint is fired. The refresh token sitting in storage is never used.
Expected: the SDK POSTs grant_type=refresh_token to the token endpoint, updates storage with the new tokens, and getIdToken() resolves with the fresh ID token.
Real-world impact
A user keeps a tab open past the access token TTL (e.g., overnight). On returning to the tab and clicking anything, every authenticated query throws SPA-AUTH_CLIENT-VM-IV02. With no app-side workaround, this surfaces as a generic error page — the user is stuck and has no path to re-authenticate without manually clearing storage. They have to be told "clear storage and sign in again," which is a poor UX for an OIDC SDK whose entire value proposition is hiding this complexity.
We've shipped an application-side workaround that catches SPA-AUTH_CLIENT-VM-IV02 and calls signInSilently() (then signOut() as final fallback). It works, but every consumer of the SDK will end up writing the same code — this belongs in the SDK.
Please select the area the issue is related to
@asgardeo/react, @asgardeo/browser, @asgardeo/javascript
Version
- @asgardeo/react 0.22.4
- @asgardeo/browser 0.6.6
- @asgardeo/javascript 0.18.0
Environment Details
- Browser (Chrome / Firefox / Safari — reproducible in all)
- React 19
Related work
Reporter Checklist
Description
getIdToken()andgetAccessToken()go throughAsgardeoSPAClient._validateMethod(packages/browser/src/__legacy__/clients/main-thread-client.ts), which callsisSignedIn().isSignedIn()returnsfalsethe instantcreated_at + expires_in * 1000 <= Date.now(), without consulting the refresh token:_validateMethodthen rejects withSPA-AUTH_CLIENT-VM-IV02("The user is not authenticated."). No attempt to callrefreshAccessTokenis made, even though a valid refresh token is sitting in storage.#442 fixed the timer path (
refreshAccessTokenAutomaticallynow immediately refreshes when the token is already expired). But the on-demand path — what every consumer hits when they callgetIdToken()orgetAccessToken()after the timer has missed its window — is unchanged. In production this leaves apps in a broken state any timeperiodicTokenRefresh'ssetTimeoutdoesn't fire in time: browser background-tab throttling, system sleep across the original expiry boundary, clock skew, or simply a tab idle past the access token TTL.Most other OIDC SPA SDKs (
oidc-client-ts,react-oidc-context, MSAL) refresh transparently fromgetAccessToken-style calls and only reject when refresh itself is impossible. Asgardeo's current behaviour forces every consumer to wrapgetIdToken()with their ownsignInSilently/refreshAccessTokenfallback to avoid surfacing a generic error to users.Expected behaviour
When
getIdToken()/getAccessToken()is called and the access token is expired but a refresh token exists:refreshAccessTokentransparently.Equivalent option: make
isSignedIn()treat "access token expired AND refresh token present" as still signed in, so_validateMethoddoesn't reject — then have downstream callers (getAccessToken, the http layer) trigger the refresh before reading.Steps to reproduce
@asgardeo/react.periodicTokenRefreshtimer was scheduled around the original expiry, so it does not reschedule based on the new in-storage values).getIdToken()— e.g. an authenticated API call from the app.Observed:
getIdToken()rejects with:No network request to the token endpoint is fired. The refresh token sitting in storage is never used.
Expected: the SDK POSTs
grant_type=refresh_tokento the token endpoint, updates storage with the new tokens, andgetIdToken()resolves with the fresh ID token.Real-world impact
A user keeps a tab open past the access token TTL (e.g., overnight). On returning to the tab and clicking anything, every authenticated query throws
SPA-AUTH_CLIENT-VM-IV02. With no app-side workaround, this surfaces as a generic error page — the user is stuck and has no path to re-authenticate without manually clearing storage. They have to be told "clear storage and sign in again," which is a poor UX for an OIDC SDK whose entire value proposition is hiding this complexity.We've shipped an application-side workaround that catches
SPA-AUTH_CLIENT-VM-IV02and callssignInSilently()(thensignOut()as final fallback). It works, but every consumer of the SDK will end up writing the same code — this belongs in the SDK.Please select the area the issue is related to
@asgardeo/react, @asgardeo/browser, @asgardeo/javascript
Version
Environment Details
Related work
created_at. Closed by Fix auto refresh token logic to ensure timely token renewal #442.Reporter Checklist