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

Open
#527 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.