`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
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
- Domain
- authentication
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:
- Call
refreshAccessTokentransparently. - 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
periodicTokenRefreshtimer 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
- #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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from asgardeo/javascript
-
Bug: SignInButton render prop usage in "react-tanstack-router" sample missing "onClick={signIn}" OpenType/Bug
Difficulty 1/5 Under an hour Newbie friendliness 92/100
asgardeo/javascript#572 ·
-
Type/Bug
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
asgardeo/javascript#571 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
asgardeo/javascript#485 ·
-
Difficulty 3/5 1-2 days Newbie friendliness 56/100
asgardeo/javascript#522 · 2 comments ·
-
Type/Bug
Difficulty 3/5 1-2 days Newbie friendliness 45/100
asgardeo/javascript#519 ·
All issues in asgardeo/javascript
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
copse-dev/agent-pane#2953 ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Eynzof/Hermes-CN-Desktop#610 ·
-
bug clawsweeper:linked-pr-open clawsweeper:needs-live-repro clawsweeper:no-new-fix-pr impact:message-loss issue-rating: 🐚 platinum hermit P2 regression
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
calcite-components needs triage refactor
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Esri/calcite-design-system#15203 ·