asgardeo / asgardeo/javascript
`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
- Dominant language
- TypeScript
- Stars
- 18
- Forks
- 67
- Avg merge
- 4h 6m
- Merged PRs (30d)
- 13
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**:
```js
// 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):
```js
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
- [x] I have searched the existing issues and this is not a duplicate.
- [x] I have provided all the necessary information.
- [x] I have tested the issue on the latest version of the package.
Contributor guide
Assessment
This issue has not been assessed yet.