Skip to content

getIdToken() / getAccessToken() returns SPA-AUTH_CLIENT-VM-IV02 instead of triggering an on-demand refresh when the access token has expired but a valid refresh token exists #527

@rksk

Description

@rksk

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:

  1. Call refreshAccessToken transparently.
  2. Return the new token.
  3. 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

  1. Sign in to any SPA using @asgardeo/react.
  2. 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));
  3. Wait ~11s (the existing periodicTokenRefresh timer was scheduled around the original expiry, so it does not reschedule based on the new in-storage values).
  4. 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

  • I have searched the existing issues and this is not a duplicate.
  • I have provided all the necessary information.
  • I have tested the issue on the latest version of the package.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions