`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

Open
#527 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
52/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Quiet
Tech stack
react, typescript

Research direction

Start with AsgardeoSPAClient._validateMethod in packages/browser/src/legacy/clients/main-thread-client.ts and the isSignedIn implementation in packages/javascript around lines 1893-1905; trace how getIdToken(), getAccessToken(), and refreshAccessToken interact. Reproduce the expired-access-token scenario using the listed sessionStorage steps. Done means a valid refresh token causes a token-endpoint refresh and fresh token return, while refresh failures still reject.

Written by the indexing model from the issue text.

Description

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
  • #442 (merged) — fixed timer mis-calculation. Does not address the on-demand-refresh gap reported here.
  • #445, #478 — refresh-token race conditions. Tangential.
  • #346 (closed) — timer ignoring created_at. Closed by #442.
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.
Dominant language
TypeScript
Stars
18
Forks
67
Avg merge
4h 6m
Merged PRs (30d)
13

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from asgardeo/javascript

All issues in asgardeo/javascript

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.