npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@antzsoft/wso2-auth-web

v1.6.0

Published

Framework-agnostic WSO2 IS auth client — login, logout, token refresh, change password. Works with React, Vue, Next.js, or plain JS.

Readme

@antzsoft/wso2-auth-web

Framework-agnostic OAuth2 / PKCE client for WSO2 Identity Server 7.x.

Handles login, logout, token refresh, change password (with OTP), and auto-logout on session expiry. Works with React, Vue 3, Next.js (with or without proxy routes), and plain JavaScript/TypeScript.


What's new in v1.6.0

Fixed: apps that mount useAntzAuth() more than once restarted the session poll on every re-render. The poll timer lives on the client, but it is started and stopped by per-component effects. An app with a session guard in the layout and a hook on the page — both sharing one client — had two instances driving one timer, and any instance's effect cleanup stopped it for everyone. The effect body then immediately re-armed it, restarting the interval from zero, so the poll never survived long enough to reach sessionPollIntervalSeconds. Instead of one session-info every few minutes, it fired continuously — each tick refreshing a token and issuing another session-info behind it.

startSessionPoll()/stopSessionPoll() now reference-count their callers: the timer stops only when the last interested instance releases it, so one hook re-rendering or unmounting cannot kill a poll another still depends on. The React effect additionally depends only on whether the session is authenticated (the expiry callback is read through a ref at fire time), so ordinary re-renders no longer tear the timer down and rebuild it. stopSessionPoll(true) force-stops regardless of refcount and is used internally when a session is confirmed dead.

On a fast network the restarts were invisible, since each rebuilt timer still had far longer than one request's duration before it next fired; on a slow one they made the poll effectively continuous. Note that a session-info shown as (unknown)/0.0 kB in DevTools is not diagnostic of this bug on its own — a response the browser rejects for CORS reasons (for example a 401 returned without an Access-Control-Allow-Origin header) is discarded before JavaScript sees it and looks identical in the Network panel, while being a server-side issue this fix does not address.

Also fixed: the session poll and the refresh timer could pile up requests on a slow network. On a fast connection neither bug was visible; on a slow or flaky one (throttled 4G, poor mobile signal) they compounded into a steady stream of token + session-info requests, most of which the SDK's own 15-second timeout then aborted. Two independent causes, both fixed:

  • The session poll overlapped itself. startSessionPoll() used setInterval, which does not wait for its async callback. A tick that ran longer than sessionPollIntervalSeconds — easy once session-info runs to the timeout, and more likely still when the tick also has to refresh an expiring token first — was joined by the next tick, then the next, each issuing its own requests. The poll is now a self-rescheduling setTimeout that arms the next tick only after the current one settles, so the gap between ticks is the interval plus however long the tick took, and overlap is structurally impossible.
  • The proactive refresh timer busy-looped after a network failure. When a refresh failed, expiresAt had not moved, so the next delay — expiresAt - now - refreshBuffer — was already negative and Math.max(0, …) clamped it to 0. The timer re-fired immediately, failed again, and re-armed at 0: an unthrottled retry loop bounded only by the fetch timeout. Retries now back off exponentially (30s, 60s, 120s, … capped at 5 minutes), resetting to normal scheduling on the first success.

Also, a poll tick now returns early while a token refresh is already in flight — a refresh in progress is already asking the server the same question the poll asks, so the extra session-info round trip was duplicate load precisely when the connection could least afford it.

No API change and no configuration change: apps only need the new version. If you had lowered sessionPollIntervalSeconds or disabled the poll to work around the request storm, you can restore your intended values. The same overlap fix landed in the React Native, ThingsBoard, and Blazor SDKs.


What's new in v1.5.9

New option: revalidateOnFocus, default true. Cross-app session changes (a different app logged this browser out globally, or a different user took over the shared session) were only ever detected on a fresh page load — a tab that's been sitting open and regains focus only ever refreshed its own token, with no cross-app awareness until the next reload. revalidateOnFocus (React useAntzAuth/Vue composable) re-runs the same resolveSession() check on tab refocus that already runs on mount — enabled automatically, no config change needed for apps already using enableSilentSso (also default true).

Trade-off accepted by default, mirroring enableSilentSso's own default: if the shared session changed while the tab was unfocused, resolveSession() can perform a real top-level redirect to WSO2 and back — the same navigation that already happens on page load — now potentially firing on refocus too, mid-work, with no explicit reload. Pass revalidateOnFocus: false to opt back out to the previous behavior (plain token refresh only on refocus, no cross-app check). The throttle between two focus-triggered checks is configurable via revalidateOnFocusMinIntervalSeconds (default 300 = 5 minutes; 0 disables it) — lower it for faster cross-app logout detection, raise it if even one check per 5 minutes is too eager for a page with unsaved work.

Throttle default is 5 minutes, and the window starts at mount. An earlier build seeded the throttle clock at 0, which meant Date.now() - 0 exceeded any configured interval — so the first refocus after a page load always fired, and the configured window only ever gated the second one. A large value like 3600 therefore looked completely ignored: load, alt-tab away, come back, and it navigated immediately. The clock now starts at mount, where the mount-restore already ran the same resolveSession() check, so a refocus shortly after load is correctly throttled.

Safe by construction, not just by convention: client.resolveSession() already deduplicates concurrent calls (a focus check that fires while the mount-restore's own call is still in flight joins the same promise rather than starting a second redirect), and the hook/composable additionally debounces rapid focus/blur cycling. No effect when enableSilentSso is false.

A callback with no stored PKCE state no longer reports itself as a CSRF attack. handleCallback() threw the same "State mismatch — possible CSRF attack" in two very different situations: no state was stored at all, or a state was stored and differed from the one in the URL. Only the second is a genuine CSRF signal; the first is almost always benign and has a completely different fix, so the shared message sent integrators hunting for an attacker that was never there.

A missing state now throws a distinct, actionable message. The usual causes, in rough order of likelihood:

  • handleCallback() ran twice for the same ?code=. The first call succeeds, and exchangeCode() correctly consumes the code and clears the state and verifier (single-use credentials must not be replayable). A second call then finds nothing. In a component-based app the common trigger is a remount: if a parent renders a different element type either side of login — e.g. a bare fragment while unauthenticated and a context provider once authenticated — React cannot reconcile the two trees, so it unmounts and remounts the subtree. The remounted component gets fresh refs, so a useRef-based "already ran" guard resets and the mount effect fires again. Guard on something that survives a remount (module scope, keyed to the code) or, better, keep the parent's element type stable across login.
  • login() and the callback used different storage. sessionStorage is scoped per-origin and per-tab: http://localhost:3000 and http://localhost:3001 are different origins, and a callback opened in a new tab cannot see the tab that started the login.
  • Something cleared the antz_auth_* keys mid-login — a blanket sessionStorage.clear(), or app code deleting SDK keys directly.

A present-but-mismatched state still throws the CSRF error, unchanged. No API change — only the message and the condition that selects it.


What's new in v1.5.6

New: storageKeyPrefix config option — for apps sharing a domain. localStorage/sessionStorage are scoped to the origin, not the path, so two Antz apps with different clientIds deployed under the same domain (e.g. https://app.dev.antzsystems.com and https://app.dev.antzsystems.com/d/analytics) previously shared one set of token keys — whichever app loaded second would read the other's tokens, see they didn't match its own clientId, and log itself out. Set a unique storageKeyPrefix per app to fix this. Purely additive — omitting it keeps today's unprefixed keys, unchanged. See Multiple apps on one domain.


What's new in v1.5.5

Silent cross-app check: "no_session" vs. "app_access_denied" is now resolved correctly, with a ready-made message for either. WSO2 can return error=login_required (and interaction_required/consent_required) for two different outcomes on a silent prompt=none check — "nobody's logged in anywhere," or "a shared session exists but this app's own conditional-auth policy rejects it" — with the identical error code and sometimes identical error_description. This SDK previously treated all three codes as always meaning "no_session"; confirmed against a real WSO2 tenant that this was wrong whenever the second case was the real one. Fixed using the same document.referrer heuristic AntzAuthorizeRejectedError already uses for the manual-login case. New client.consumeSilentLogoutMessage() also gives you a ready-made, end-user-safe string for the resulting reason (onBeforeSessionSwitch/onSharedSessionChanged itself is unchanged — still pure notification, no message field) — see resolveSession() and Customizing error messages.

AntzAuthorizeRejectedError.message is now always end-user-friendly — never WSO2's raw error/error_description text.

v1.5.4 introduced AntzAuthorizeRejectedError (see below) but its .message still fell back to WSO2's own error_description verbatim (e.g. "Access denied: Antz web access not provisioned for this user.") whenever .sessionExisted wasn't confidently true. That text is written for an admin reading server logs, not someone trying to sign in — showing it directly in a login form reads like an internal error leaking through.

.message now picks between two friendly variants, same as before but with a friendly default instead of a raw-text one:

| .sessionExisted | .message | | --- | --- | | true (a different, already-live session was detected) | "Another account is already signed in on this browser, and it doesn't have access to this app. Please sign out of that account first, then sign in again as {expectedUser}." | | false or undefined (no session detected, or undeterminable) | "You don't have access to this application. Please contact your administrator if you believe this is a mistake." |

WSO2's own raw text is still available via .wso2Error/.wso2ErrorDescription if your app wants it — this change only affects the default .message, and only for this one error type. Apps already catching AntzAuthorizeRejectedError to build their own message (rather than relying on .message) are unaffected.


What's new in v1.5.4

authorizeAppAccess now receives a second argument telling you which call site invoked it — { source: "manual" | "silent" }.

If your app already has its own pre-flight check before calling login(), authorizeAppAccess running again inside the manual login callback is redundant — the same check happening twice. source lets you skip it there and only enforce the rule on the silent cross-app path, which has no pre-flight moment of its own:

authorizeAppAccess: async (user, { source }) => {
  if (source === "manual") return true; // already checked before login()
  return await myBackend.isUserAllowedInThisApp(user.sub);
},

| source | When | | ----------- | --------------------------------------------------------------------- | | "manual" | A user-initiated login(), handled inside handleCallback(). | | "silent" | The cross-app silent check, handled inside resolveSession()'s return trip. |

No change if you don't read the second argument — existing authorizeAppAccess: async (user) => ... implementations keep working exactly as before; the extra argument is simply available if you want it.

New AntzAuthorizeRejectedError — replaces a guessed message with WSO2's own error text when a hinted login is rejected at /authorize itself.

handleCallback()'s manual path used to assume that any OAuth error returned alongside a recorded loginHint meant "a different, already-live session disagrees with the hint," and threw AntzSessionUserMismatchError with a synthesized "another account is already signed in on this browser" message. That assumption is wrong whenever the real cause is different: WSO2 can also reject the hinted user directly (e.g. a conditional-auth rule for "this user isn't provisioned for this app") — both surface identically, commonly as error=login_required, and WSO2 can send the identical error_description text for both.

AntzAuthorizeRejectedError resolves this a different way: .sessionExisted (boolean | undefined) is a best-effort detection of which cause actually happened, based on a real difference in the redirect chain — WSO2 resolves a hinted login against an existing session in a single /authorize round trip with no page render in between, whereas a browser with no session at all is bounced through WSO2's own hosted login.do page first. That extra hop is visible via document.referrer: it's still this app's own origin when no such hop occurred (a session existed), and empty/foreign when it did (no session existed). This is a heuristic — a real, observed behavior of the redirect chain, not a WSO2 API contract — so .sessionExisted can be undefined ("can't tell").

.message uses this: the specific "another account is already signed in" wording when .sessionExisted === true, and WSO2's own error_description verbatim otherwise (falling back to the raw error code if WSO2 sent no description). .expectedUser/.wso2Error/.wso2ErrorDescription/.sessionExisted are all available for apps that want their own wording, and error_code: "wso2_authorize_rejected" is set — distinct from session_user_mismatch, which is now reserved for the real, confirmed post-exchange mismatch case. See Customizing error messages.

try {
  await client.handleCallback();
} catch (err) {
  if (err instanceof AntzAuthorizeRejectedError) {
    setError(err.message); // already picks the right wording via .sessionExisted
  }
}

autoSwitchAccount: true still auto-recovers with a forced credential prompt first, regardless of which of the two causes it actually was — harmless either way, since a genuine mismatch resolves it, and a direct rejection just fails again the same way on the retry.


What's new in v1.5.3

New authorizeAppAccess config option — your own authorization rule for the silent cross-app login case:

WSO2 saying yes (the user is provisioned for this app's clientId) is not the same as this app wanting to admit them — you might have a role/group/feature-flag check, or your own backend's access rule, that WSO2 has no way to express. A manual login can already gate this with a pre-flight check before ever calling login() (many real deployments already do this against their own backend). The silent path (resolveSession()NeedsSilentCheck → the prompt=none redirect) has no such pre-flight moment — nothing is clicked to hang a check on — so without this hook, a user your app's own rules would reject is admitted automatically the instant a different app's login makes WSO2's shared session recognize them.

const client = new AntzAuthClient({
  // ... baseUrl, clientId, etc.
  authorizeAppAccess: async (user, { source }) => {
    return await myBackend.isUserAllowedInThisApp(user.sub);
  },
});

Return false and the SDK revokes the just-issued refresh token at WSO2 (this app's own clientId only — commonAuthId and every other app are left completely untouched), never stores it, and throws AntzAppAccessDeniedError (from handleCallback()) or reports it through onBeforeSessionSwitch/onSharedSessionChanged with reason: "app_rejected_by_own_policy" (on the silent path — nothing in-page is waiting for a silent check's result, so it can't throw into a void).

Why this runs BEFORE token storage, not reactively from onBeforeSessionSwitch: that hook is a notification that fires once the outcome is already final — on the different_user case, the new user's tokens are already stored by the time it runs. Calling logout() from inside that hook races whatever page logic is already reading the just-stored token, which can produce a visible flash of signed-in state or a redirect loop. authorizeAppAccess runs inside the same call as the token exchange itself, before anything is committed — so a rejection means the token was never valid to any caller, not "valid then revoked."

New reason value on onBeforeSessionSwitch/onSharedSessionChanged:

| reason | Meaning | This app's tokens | | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | | "app_rejected_by_own_policy" | A shared session exists and WSO2 issued this app a real token for it, but authorizeAppAccess rejected the user. | Revoked + never stored (this app only) |

Default: undefined — no additional check, WSO2's own decision is final (unchanged behavior if you don't set this).


What's new in v1.5.1

resolveSession() — silent cross-app auto-login, auto-logout, and account-switch detection for apps sharing a WSO2 tenant:

The headline addition: a single new method, resolveSession(), that apps call once on page load instead of getAccessToken(). It decides between "already authenticated," "silently authenticated via a different app's live SSO session," and "genuinely not authenticated" — without ever showing a login screen unless one is truly needed.

const result = await client.resolveSession();

if (result.status === "authenticated") {
	// result.user   — UserClaims | null
	// result.tokens — same TokenSet shape handleCallback() returns
} else {
	// result.status === "unauthenticated" — show your login screen
}

What it actually does, in order:

  1. Checks this app's own tokens first (getAccessToken(), with its existing refresh fallback).
  2. A valid local access token is not treated as sufficient proof of being logged in — it only proves this app hasn't expired its own copy, not that the browser's shared commonAuthId session still agrees. A different app calling logout({ global: true }), or a different user taking over the shared session, doesn't touch this app's own token at all. So resolveSession() also verifies against the shared session on every call, outside a short grace window (see skipVerifyAfterLoginSeconds below).
  3. That verification is a real, top-level browser redirect to /authorize?prompt=none and back — not a hidden iframe. (An iframe was the first implementation tried; it reliably failed, because browsers with third-party cookie blocking enabled — Chrome's evolving default, and unconditional in Incognito — do not send commonAuthId on a cross-site iframe subresource request even though the cookie is valid. WSO2's own engineering team has publicly documented this exact failure mode. A top-level navigation is not subject to that restriction.) When a shared session exists, WSO2 skips its login form entirely and redirects straight back — usually well under a second, but a genuine navigation nonetheless, not an invisible background call.
  4. If this app has no local token at all, step 3 runs directly — if a different app already has a live session, this app picks it up silently, no login form shown.

On your callback page: handleCallback() / useAntzCallback() automatically detects and handles the return trip from this check — no separate wiring needed. It's safe to call resolveSession() from a root layout guard that wraps every route, including the callback page itself; resolveSession() detects when it's on that page and skips itself so it doesn't race the in-progress code exchange.

New onBeforeSessionSwitch config option (React/Vue: onSharedSessionChanged hook option — same event):

onBeforeSessionSwitch?: (info: {
  reason: "no_session" | "different_user" | "app_access_denied";
  previousUser?: UserClaims;
  newUser?: UserClaims;
}) => void | Promise<void>;

Awaited before resolveSession() clears or switches this app's tokens:

| reason | Meaning | This app's tokens | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | | "no_session" | The shared session is gone entirely — nobody is logged in anywhere in this browser. | Revoked + cleared | | "different_user" | The shared session now belongs to a different (and provisioned) user. | Replaced with the new identity's tokens | | "app_access_denied" | A shared session exists (someone logged in elsewhere), but WSO2 rejected it for this app specifically — e.g. the live user isn't provisioned for this app's client_id. Not a global logout: commonAuthId and every other app are left completely untouched. A different app that the live user is provisioned for still picks them up normally. | Revoked + cleared (this app only) |

Use this hook to clear any of your own backend/business session state tied to the previous user — the SDK's own cleanup (local tokens, WSO2 revoke) happens independently of this; it's purely a notification hook for app-level side effects.

New skipVerifyAfterLoginSeconds config option (default 5):

Right after a real login (or a successful silent check) on a given browser tab, resolveSession() skips the shared-session verification redirect for this many seconds — a session proven valid moments ago can't have been logged out from elsewhere in that same instant, and WSO2's commonAuthId cookie from the login that just completed isn't guaranteed to be visible to an immediate follow-up request. Tracked per browser tab (not shared across tabs of the same app, even though refresh_token is) — a different tab's login must never suppress this tab's own verification.

New enableSilentSso hook option (React useAntzAuth/Vue composable, default true):

resolveSession() is opt-out per hook instance, not mandatory:

useAntzAuth(client, { enableSilentSso: true });

Setting it to false reverts that hook instance's mount-time restore to the pre-1.5.x behavior: only getAccessToken() is called (this app's own tokens, refreshed if expiring) — no cross-app check, no possible automatic top-level redirect during restore, onSessionExpired fires normally for a dead local refresh token exactly as it always did. Use this if you don't run multiple apps against the same WSO2 tenant, or want full manual control over when cross-app checks happen. AntzAuthClient used directly (vanilla JS, no framework adapter) is unaffected either way — it never called resolveSession() automatically; you choose explicitly between getAccessToken() and resolveSession() in your own code.

Fix: onSessionExpired no longer causes a cross-app logout cascade.

onSessionExpired was originally designed for one specific case: the proactive refresh timer (or a tab-focus check) found this app's own refresh token dead. Apps commonly wire it to logout(), which defaults to a global logout — a real navigation to WSO2's /oidc/logout that kills commonAuthId for every app in the browser. That's correct when the refresh token really is dead.

resolveSession()'s shared-session check introduced a second, much more common way to reach status === "unauthenticated": the silent verification simply found no shared session, or a session this app isn't provisioned for. That path already does its own narrow, local-only cleanup (see the reason table above) — it deliberately never touches commonAuthId. But the React/Vue adapters routed both outcomes through the same onSessionExpired signal, so an app wired the ordinary way would call the SDK's global logout() in response to a routine, often-transient "no session" answer. With multiple useAntzAuth() instances per page (a root layout guard and the page itself, both independently detecting the same result and each calling logout()), the resulting race meant a plain page reload could — after a few cycles — genuinely kill the shared session for every app in the browser, with no real credential expiry, account switch, or manual sign-out involved anywhere.

Fixed: onSessionExpired now fires only for this app's own credential genuinely dying (proactive refresh timer, tab-visibility check, session-info poll, explicit getAccessToken()/changePassword() calls) — never for a resolveSession()-driven outcome. No app-code changes required; existing onSessionExpired: () => logout() wiring is now safe as-is.

Fully additive to everything else. login(), handleCallback(), autoSwitchAccount, and every existing error path work exactly as before. resolveSession() is a new, optional entry point; apps that don't call it (or that set enableSilentSso: true) see no behavior change.

See docs/cross-app-sso-scenarios.html in this repo for the full scenario matrix — first-session auto-login, global logout propagation, account switching with partial provisioning, local logout, and multi-tab edge cases — each traced against the exact code branch.


What's new in v1.5.0

Multi-tab safety — getAccessToken() no longer serves a stale identity from a different tab:

access_token/expires_at live in sessionStorage (private to each browser tab) while refresh_token lives in localStorage (shared by every tab of the app, via SplitStorageAdapter). If Tab 1 logged out and back in as a different user, it correctly rewrote the shared refresh_token — but Tab 2's own cached sessionStorage access token was untouched and still looked perfectly valid (not expired), so getAccessToken() kept silently serving Tab 2's old user's token until it happened to expire naturally, even though the shared identity had already changed.

Fixed by recording a tab-local fingerprint (antz_auth_access_token_owner, sessionStorage) of which refresh_token a tab's cached access token was issued alongside. getAccessToken() now compares that fingerprint against the current shared refresh_token before trusting the cached access token — if they've diverged (a different tab rotated the shared identity), it forces a fresh refresh instead, which correctly picks up whoever is now actually logged in.

No API changes — this is an internal correctness fix. If your app opens the same app in multiple tabs, a background tab now correctly picks up an account switch performed in another tab on its next getAccessToken() call, instead of continuing to show the previous user.


What's new in v1.4.8

openPasswordRecovery() — redirect back to your app + username prefill:

  • Redirect back to the calling app after reset. openPasswordRecovery() now sends your configured redirectUri as redirect_uri on the recovery page's query string. The server-side patch (04-patch-wso2-config.sh §15) reads it off the page's Referer header and, on successful reset, redirects the browser back to redirect_uri instead of WSO2's My Account portal.
  • The redirect carries ?password_reset=success, not ?code=. WSO2 terminates the SSO session as part of a password reset, so there is no live session to mint a fresh authorization code with — the landing URL has no code/state. Your /callback handling must treat "no code + password_reset=success" as a "password changed, please sign in again" case, not an error.
  • Optional username prefill. New second argument loginHint pre-fills the recovery page's Step-1 username/email field. Sent as username (also read as login_hint) and consumed by the server-side §16 prefill patch.
  • Additive / backward compatible. Existing openPasswordRecovery() and openPasswordRecovery(true) calls are unchanged. Both new behaviors depend on the WSO2 box having the §15/§16 patches applied; on an un-patched server the extra query params are harmless no-ops.

Requires the server-side patches from deploy-scripts/scripts/04-patch-wso2-config.sh (§15 ANTZ_REDIRECT_CAPTURE/ANTZ_REDIRECT_OVERRIDE and §16 ANTZ_USERNAME_PREFILL). See docs/password-recovery-redirect-investigation.md.


What's new in v1.4.7

All network calls are now timeout-guarded (15 s):

  • Browser fetch() has no default timeout — previously a stalled request (token refresh against an unreachable server, a session-info poll on a flaky network, a hung change-password call) could leave the promise pending indefinitely, hanging the caller and silently stalling token renewal. Every SDK network call now runs through an internal AbortController with a 15-second deadline; on expiry the request aborts and rejects with an AbortError.
  • No behavior change for healthy flows — the timeout only fires when a request genuinely stalls past 15 s.
  • No unintended logout: the refresh and session-poll paths already classify AbortError as a transient network error (see v1.4.3), so a timeout keeps the session and retries — it never logs the user out.
  • Covers every call: login/callback token exchange, refreshTokens(), logout() revoke, sendOtp(), changePassword(), getSessionInfo(), and apiFetch().
  • apiFetch() respects your own signal: if you pass an AbortSignal in the options, it is honoured alongside the SDK's timeout — whichever aborts first wins. Wrap awaited calls in try/catch to handle a possible AbortError.

What's new in v1.4.3

Network error resilience — no unintended logout on offline/timeout:

  • Proactive refresh timer no longer logs out on network failure: Previously, any error during the scheduled refresh (offline, DNS failure, slow connection timeout) was treated identically to an expired refresh token — causing immediate logout. The timer now distinguishes TypeError (no network) and AbortError (timeout) from auth errors (4xx invalid_grant). On network error the timer reschedules silently; only genuine auth failures trigger logout.
  • 5xx server errors no longer cause logout: If WSO2 IS returns a 500 during token refresh (server temporarily down), the SDK now retries on the next cycle instead of logging the user out. The refresh token is still valid in this case.
  • Tab visibility and restore also resilient: The visibility-change handler and on-mount session restore both handle network errors gracefully — the user stays authenticated with their stale token until connectivity returns.
  • Session poll distinguishes network from 401: The poll skips the refresh attempt entirely on network errors and retries on the next tick. Only a confirmed 401 (token revoked) triggers the logout flow.

What's new in v1.4.1 / v1.4.2

sessionPollIntervalSeconds — remote revocation detection:

  • New opt-in config option. When set (e.g. sessionPollIntervalSeconds: 180), the SDK polls GET /api/users/v1/me/session-info every N seconds. This endpoint uses DB-backed token validation and returns 401 immediately when WSO2 revokes tokens (e.g. password changed on another device), even while the JWT signature is still valid.
  • On 401 from the poll: attempts a silent refresh first (transient error guard); if refresh also fails, calls onSessionExpired → logout.
  • Poll is paused automatically when the browser tab is hidden (visibilityState === 'hidden') and resumes on tab focus.
  • Default: 0 (disabled).

What's new in v1.4.0

Multiple concurrent device/browser sessions — no more session flip-flop:

WSO2 IS by default enforces one active token row per (user, app, scope). A second browser login would silently expire the first browser's DB row, causing 401s on that session until its proactive refresh timer fired — then the first session recovered and the second broke. They kept flipping.

This version adds sessionPollIntervalSeconds support and is the first version compatible with the WSO2 server-side fix ([oauth.jwt.renew_token_without_revoking_existing] enable = true). With that server config applied, each login gets a unique token_binding_ref UUID, so all browser/device sessions coexist independently.

Also: ChangePasswordAsync now calls LogoutAsync on success (Blazor pattern, matches React Native behaviour).


What's new in v1.3.6

Session expiry and daily check — reliability fixes:

  • onSessionExpired fires correctly on tab/browser reopen after expiry: Previously, if the browser was closed and reopened after the refresh token had expired, onSessionExpired was silently skipped because the everAuthenticated guard was false on the fresh mount (restore hadn't succeeded yet). The adapter now also checks whether refresh_token or id_token exists in storage — if so, a prior session is present and must be cleaned up (revoke + end_session), so onSessionExpired fires correctly regardless of everAuthenticated.

  • logout() works correctly after re-login: _logoutInProgress was set to true on the first logout and never reset. On subsequent logins and logouts within the same client singleton lifetime, client.logout() silently returned as a no-op — tokens were not revoked and the WSO2 SSO session was not killed. The flag is now reset after tokens are cleared.

  • Daily catch-up no longer fires immediately after login: The catch-up check (fires on mount when the scheduled time has already passed today) now skips if the user logged in after today's check time — meaning the token was freshly issued and cannot be expiring soon. Uses antz_auth_login_time (epoch ms, written at exchangeCode(), stored in localStorage). If login was before the check time (e.g. logged in at 8 AM, check time is 9:43 AM), the catch-up correctly fires when the app reopens after 9:43 AM.


What's new in v1.3.0

Session expiry callbacks — fully reliable across all frameworks and multi-instance setups:

  • onSessionExpired — React multi-instance fix: The React adapter now registers the hook-level onSessionExpired callback on the shared client object (same pattern as onDailyExpiryWarning since v1.2.15). In apps with multiple useAntzAuth() instances (e.g. AuthSessionGuard + DashboardPage), whichever instance's refresh timer fires will always find and invoke the callback.

  • refreshTokens() deduplication: When multiple useAntzAuth() instances share a client and both schedule a proactive refresh timer for the same expiresAt, the second call joins the already-in-flight promise instead of making a duplicate network request. Eliminates the double token API calls and double session-info API calls previously observed in multi-instance setups.

  • Daily expiry catch-up fix — no more immediate logout after login: antz_auth_last_daily_check is now written to localStorage at login time (in exchangeCode()) if not already set. Previously, logout() cleared this key, so the daily catch-up check fired immediately on every login when the configured check time had already passed that day — triggering onDailyExpiryWarning and logging the user out. Now the check correctly fires at most once per day.

  • antz_auth_last_daily_check moved to localStorage: The key previously fell through to sessionStorage (cleared on tab close), causing the catch-up to re-fire whenever the browser was reopened after the configured check time. It is now in localStorage so it persists across tab close and browser restarts within the same calendar day.

  • antz_auth_last_daily_check survives logout: _clearTokens() no longer removes this key, preventing the catch-up from re-firing after a logout+login cycle on the same day.


What's new in v1.2.8

onSessionExpired callback + Daily expiry check:

  • onSessionExpired — new callback in useAntzAuth(client, { onSessionExpired }) (React & Vue). Fires when the SDK detects the refresh token is dead. Replaces the loading → unauthenticated status-watch pattern in AuthSessionGuard with a simpler, explicit callback. The status-watch approach still works as a fallback.

  • Daily expiry check — new opt-in feature (enableDailyExpiryCheck: true). Fires onDailyExpiryWarning once per day at a configurable local time (default 5 AM) when the refresh token will expire within a configurable window (default 24 h). Distinct from onSessionExpired — this is a proactive warning, not a hard expiry. The SDK does not auto-logout; your callback decides the UX. Handles all cases: app open continuously (setTimeout), tab closed and reopened (catch-up on mount), tab hidden and restored (visibility listener).

  • Refresh token expiry stored after logingetSessionInfo() is called fire-and-forget after every login and after every token refresh. The refresh token's absolute expiry is persisted in localStorage under antz_auth_refresh_expires_at so the daily check always has a current value without a network call. Cleared on logout().

New config fields: enableDailyExpiryCheck, dailyCheckHour, dailyCheckMinute, expiryWarningWindowSeconds, onDailyExpiryWarning.


What's new in v1.2.15

onDailyExpiryWarning — multi-instance propagation fix (React):

In Next.js App Router and any app with multiple useAntzAuth() calls for the same client (e.g. a root layout AuthSessionGuard + individual page components), only one hook instance's daily-check timer fires — whichever reached "authenticated" first. Previously, if the page component's instance fired the timer and that instance had no onDailyExpiryWarning callback, the callback registered in AuthSessionGuard was silently skipped.

The React adapter now propagates the hook-level onDailyExpiryWarning to the shared client object when it is set. Any hook instance whose timer fires will use the callback registered by whichever instance has it — regardless of which instance won the race. This means you only need to register onDailyExpiryWarning once (in AuthSessionGuard), not in every component.

expiryWarningWindowHours renamed to expiryWarningWindowSeconds:

The config field now accepts seconds instead of hours, making it practical to use small values for testing (e.g. 300 for a 5-minute window) without waiting a full hour. The default is 86400 (24 hours). Rename the field in your AntzAuthClient config if you were passing it explicitly — the default behaviour is unchanged.


What's new in v1.2.7

logout() — fundamental fix for SSO session not being killed (cross-origin CORS):

The previous approach used fetch(POST /oidc/logout, { credentials: 'include' }) to terminate the WSO2 SSO session. This is a cross-origin request (your app origin ≠ auth.antzsystems.com). Browsers block the session cookie (commonAuthId) on cross-origin fetch unless the server responds with Access-Control-Allow-Credentials: true and a specific Access-Control-Allow-Origin — which WSO2 does not send for /oidc/logout. So the cookie was never transmitted, WSO2 could not find the SSO session, and the session stayed alive. This caused silent re-login on every browser and every machine — some appeared to work by coincidence (e.g. if WSO2's own page handled the redirect differently).

logout() now uses a browser GET navigation to WSO2's end_session endpoint with id_token_hint and post_logout_redirect_uri as query parameters. A browser navigation is not a CORS request — cookies are sent normally, WSO2 receives commonAuthId, kills the session, and redirects back to post_logout_redirect_uri. No WSO2 logout confirmation page is shown when post_logout_redirect_uri is registered.

Required WSO2 Console configuration:

Add your app's base URL as an allowed logout callback in WSO2 Console:

Applications → [your app] → Protocol → Allowed logout callback URLs → add https://yourapp.com (or http://localhost:3001 for local dev)

This must match postLogoutRedirectUri in your AntzAuthClient config (defaults to redirectUri if not set).

Network tab — what you'll see now:

| Before (v1.2.6 and earlier) | After (v1.2.7) | | --------------------------------------- | --------------------------------------------- | | revoke (fetch) | revoke (fetch) | | oauth2_logout.do (navigation — wrong) | Navigation to oidc/logout?id_token_hint=... | | Broken — SSO session not killed | WSO2 kills session, redirects back to app |

The oidc/logout navigation is not a fetch so it does not appear as a separate network entry — the whole page navigates to WSO2 and back, just like the initial login redirect.


What's new in v1.2.6

logout() — fixed oauth2_logout.do browser navigation in Safari:

The POST /oidc/logout call now includes post_logout_redirect_uri in the request body. Without it, WSO2 IS responds with a 302 redirect to its internal oauth2_logout.do page. Safari follows this redirect as a full browser navigation — not as a transparent fetch redirect — which cancelled the remaining JavaScript execution: the revoke call (if still in flight) was aborted, and window.location.href never fired. Adding post_logout_redirect_uri tells WSO2 to redirect back to the app after session termination; the fetch follows the redirect silently within JS and returns normally.

Dashboard sample app — fixed double-logout useEffect:

The useEffect in the dashboard page that watches status === "unauthenticated" was using a bare equality check. This fired on every render where status was unauthenticated — including during the manual sign-out path where client.logout() was already in progress. Changed to a transition guard (authenticated → unauthenticated) using a prevStatus ref, so it only fires when the proactive refresh timer or tab-visibility check detects mid-session expiry, not during a manual sign-out that's already being handled.


What's new in v1.2.5

logout() — fixed silent re-login in Safari and some Chrome builds:

Both fetch calls inside logout() (POST /oauth2/revoke and POST /oidc/logout) now use keepalive: true. Without this flag, browsers that start navigation before a fetch response arrives (Safari, and some Chrome installs on certain platforms) would cancel the in-flight requests. The end_session POST to WSO2 was never delivered, leaving the commonAuthId SSO session cookie alive. On the next login() call, WSO2 found a valid SSO session and silently re-authenticated the user without showing the credentials prompt.

keepalive: true instructs the browser to complete the request even if the page navigates away — the same mechanism used by navigator.sendBeacon, but with full POST body and credentials: include support.

React adapter — logout() race condition fixed:

The React logout() wrapper no longer sets status = "unauthenticated" before client.logout() reads the tokens from storage. Previously, the state update triggered a React re-render that could fire effects while client.logout() was still reading refresh_token and id_token, creating a narrow race window. State is now cleared after client.logout() returns (navigation inside client.logout() means this line only runs if postLogoutRedirectUri is the current page).


What's new in v1.2.3

id_token now persists in localStorage (via SplitStorageAdapter):

The id_token is now stored in localStorage alongside the refresh_token. This is required so that logout() can pass id_token_hint to POST /oidc/logout when the app is reopened after the refresh token has expired — at that point sessionStorage is empty, but id_token must still be available to kill the WSO2 SSO session.

Updated storage layout:

| Token | Storage | Survives browser close? | | ---------------------- | ---------------- | ------------------------------------------------------- | | refresh_token | localStorage | Yes — persists for the 24h refresh token TTL | | id_token | localStorage | Yes — required for SSO session termination on next open | | access_token, expiry | sessionStorage | No — cleared on tab/browser close |

refreshTokens() no longer clears tokens on failure:

Previously, refreshTokens() called _clearTokens() in its catch block — this wiped id_token from storage before logout() could read it, causing the POST /oidc/logout call to send no id_token_hint, leaving the WSO2 SSO session alive. The catch block is now a clean return null — tokens are only cleared by logout() after it has used them.

AuthSessionGuard — required in your root layout:

Apps must mount an AuthSessionGuard component (or equivalent) once in their root layout. This guard watches for the loading → unauthenticated status transition — which is the signal that the app was reopened after the refresh token expired — and calls logout() to kill the WSO2 SSO session before the user clicks Sign In. Without this guard, the SSO session is never terminated on the "reopen after expiry" path, and WSO2 silently re-authenticates the user on the next login() call.

See the AuthSessionGuard — required root layout component section for the exact pattern for each framework.


What's new in v1.2.0

SplitStorageAdapter is now the default — 24-hour sessions that survive browser close:

The default storage strategy changed from SessionStorageAdapter (everything in sessionStorage, lost on tab/browser close) to SplitStorageAdapter (split between localStorage and sessionStorage):

| Token | Storage | Survives browser close? | | ---------------------- | ---------------- | ------------------------------------------------------- | | refresh_token | localStorage | Yes — persists for the 24h refresh token TTL | | id_token | localStorage | Yes — required for SSO session termination (see v1.2.3) | | access_token, expiry | sessionStorage | No — cleared on tab/browser close |

This means users who reopen your app within 24 hours are silently restored — no login screen. After 24 hours (or after an explicit logout), they are prompted to log in again.

Silent session restore on reopen — fixed:

Previously, the React and Vue adapters would bail on restore if the access token was expired (e.g. after >15 min with the tab closed), without attempting a silent refresh via the refresh token. This caused a spurious unauthenticated flash and unnecessary re-login on reopen. The restore logic now always calls getAccessToken(), which silently refreshes using the stored refresh token when the access token is expired. The user sees status = 'loading' briefly, then status = 'authenticated' — no login screen.

SSO session correctly killed on refresh token expiry — FORCE_LOGIN removed:

With sessionStorage as the default, closing the browser cleared all tokens before logout() could fire — WSO2's SSO session cookie (commonAuthId) stayed alive. On next open, the app called login(), WSO2 found a valid SSO session, and silently authenticated the user (no login screen). The old workaround was a FORCE_LOGIN flag that appended prompt=login to the next /authorize call.

With SplitStorageAdapter, the refresh token survives browser close. On next open, getAccessToken() detects the expired access token, attempts a refresh — if the refresh token is also expired, refreshTokens() returns null, handleExpired() fires, logout() runs, and the SSO session is terminated via POST /oidc/logout before the user ever clicks Sign In. By the time login() is called, WSO2 has no alive SSO session to reuse. No prompt=login flag is needed.

The FORCE_LOGIN internal key has been removed. No changes required in your app code.

SplitStorageAdapter exported from package root:

import { SplitStorageAdapter } from "@antzsoft/wso2-auth-web";

You only need this if you want to reference the adapter explicitly. No changes needed if you rely on the default.

Required app-side changes when upgrading from v1.1.x:

The package handles session restore correctly, but your app's startup code must also be updated. Any code that uses client.isAuthenticated() as a gate at page load or in route guards will break — it returns false synchronously before the silent refresh has run.

See the Migration: startup and route guard patterns section for exact before/after examples for React, Vue, Next.js, and Vanilla JS.


What's new in v1.1.17+

getSessionInfo() — query token expiry durations from WSO2:

Calls GET /api/users/v1/me/session-info on WSO2 IS and returns the configured expiry durations for the current access and refresh tokens. Useful for showing users when their session will end, or for building adaptive refresh strategies.

const info = await client.getSessionInfo();
// {
//   access_token_expires_at: 1745616400,
//   access_token_expires_in_seconds: 3542,
//   refresh_token_expires_at: 1745702800,
//   refresh_token_expires_in_seconds: 89942,
// }

Throws:

  • AntzSessionExpiredError — access token is expired or missing
  • AntzApiError — any other error from the endpoint

What's new in v1.1.15+

decodeToken() — decode any JWT access token locally:

A new named export decodeToken decodes any JWT string without a network call. Useful for displaying access token claims (sub, roles, tenant, exp, iss, etc.) on a dashboard:

import { decodeToken } from "@antzsoft/wso2-auth-web";

const token = await client.getAccessToken();
const claims = decodeToken(token!);
// { sub: "...", roles: [...], tenant: "dev", exp: 1745612800, ... }

Returns Record<string, unknown> | null. Returns null if the string is not a valid JWT or decoding fails.


What's new in v1.1.10+

logout() is now fully fetch-based — no browser redirect to WSO2:

Previously logout() redirected the browser to WSO2's /oidc/logout endpoint, which could show a "Are you sure you want to log out?" confirmation page. Starting in v1.1.10, logout is handled entirely via fetch:

  1. POST /oauth2/revoke — revokes the refresh token server-side
  2. POST /oidc/logout with credentials: 'include' — terminates the WSO2 SSO session using the browser's session cookie, with no browser redirect and no confirmation page

logout() is now async and returns Promise<void>:

Update your call sites if you need to await it:

// Before (v1.1.9 and earlier)
logout: () => void

// After (v1.1.10+)
logout: () => Promise<void>

React adapter returns accessToken:

useAntzAuth() now returns accessToken: string | null — a reactive state value that updates automatically after every background refresh. No need to call getAccessToken() just to display the current token.

Vue adapter returns status and accessToken:

useAntzAuth() now returns status (reactive ref matching the React adapter) and accessToken in addition to the existing fields.

React 18 Strict Mode protection:

useAntzCallback and useAntzAuth's session-restore effect are both protected by useRef run-once guards — token exchange and session restore fire exactly once per navigation, even in Strict Mode's double-mount development behavior.


Contents


What's in the package

| Export | Description | | ----------------------- | --------------------------------------------------------------------------------------------- | | AntzAuthClient | Core client class — works in any JS environment | | useAntzAuth | React hook (from @antzsoft/wso2-auth-web/react) | | useAntzCallback | React callback hook (from @antzsoft/wso2-auth-web/react) | | useAntzAuth | Vue 3 composable (from @antzsoft/wso2-auth-web/vue) | | useAntzCallback | Vue 3 callback composable (from @antzsoft/wso2-auth-web/vue) | | SplitStorageAdapter | Default — refresh token in localStorage (24h persistence), access token in sessionStorage | | SessionStorageAdapter | All tokens in sessionStorage — cleared on tab/browser close | | LocalStorageAdapter | All tokens in localStorage — survives page reload, higher XSS exposure | | MemoryStorageAdapter | In-memory storage — SSR / testing, lost on page reload | | decodeToken | Decodes any JWT string locally — no network call. Returns all payload claims | | Error classes | Typed errors for every failure scenario |


Installation

# npm
npm install @antzsoft/wso2-auth-web

# yarn
yarn add @antzsoft/wso2-auth-web

# pnpm
pnpm add @antzsoft/wso2-auth-web

For React projects, react >= 18 must be installed (peer dependency).
For Vue projects, vue >= 3 must be installed (peer dependency).


Configuration

import { AntzAuthClient } from "@antzsoft/wso2-auth-web";

const client = new AntzAuthClient({
	baseUrl: "https://auth.antzsystems.com", // WSO2 IS base URL, no trailing slash
	clientId: "your-client-id", // OAuth2 client_id from WSO2 Console
	redirectUri: "https://yourapp.com/callback", // Must be registered in WSO2 Console
	tenant: "dev", // Tenant: "dev" | "uat" | "prod" — omit for carbon.super
	scopes: ["openid", "profile", "email", "roles"], // OAuth2 scopes
	proxyUrl: "/api/auth/change-password", // Optional — see CORS section below
	storage: new SplitStorageAdapter(), // Optional — default: SplitStorageAdapter
	postLogoutRedirectUri: "https://yourapp.com", // Optional — default: redirectUri
});

Config options

| Option | Type | Required | Description | | ----------------------------- | --------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | baseUrl | string | Yes | WSO2 IS base URL (e.g. https://auth.antzsystems.com) | | clientId | string | Yes | OAuth2 client_id registered in WSO2 Console | | redirectUri | string | Yes | Callback URL — must exactly match WSO2 Console registration | | tenant | string | No | Tenant domain (dev, uat, prod). Omit for root org (carbon.super) | | scopes | string[] | No | OAuth2 scopes. Default: ["openid", "profile", "email"] | | proxyUrl | string | No | Base URL for same-origin proxy routes. Required when WSO2 CORS is not configured. See CORS section | | storage | StorageAdapter | No | Token storage adapter. Default: SplitStorageAdapter (refresh token in localStorage, access token in sessionStorage) | | storageKeyPrefix | string | No | Prefix for every storage key this client uses. Required when two apps with different clientIds share one origin (same domain, different path) — see Multiple apps on one domain. Default: none (unprefixed keys, unchanged from previous versions) | | postLogoutRedirectUri | string | No | Where the browser navigates after logout. Default: redirectUri. | | refreshBufferSeconds | number | No | Seconds before access token expiry to proactively refresh. Default: 60 | | enableDailyExpiryCheck | boolean | No | Enable the once-per-day refresh token expiry warning. Default: false | | dailyCheckHour | number | No | Local hour (0–23) to run the daily check. Default: 5 (5 AM) | | dailyCheckMinute | number | No | Local minute (0–59) to run the daily check. Default: 0 | | expiryWarningWindowSeconds | number | No | Fire onDailyExpiryWarning if the refresh token expires within this many seconds. Default: 86400 (24 h) | | onDailyExpiryWarning | () => void | No | Called by the daily check when the refresh token is expiring soon. Register this in useAntzAuth() instead for access to React/Vue context (router, toasts, etc.) — see Daily Expiry Check. | | sessionPollIntervalSeconds | number | No | How often to poll session-info to detect remote token revocation. 0 disables. Default: 0 | | skipVerifyAfterLoginSeconds | number | No | (v1.5.1+) Seconds after a fresh login (this tab's own) that resolveSession() skips its shared-session verification redirect. See resolveSession(). Default: 5 | | onBeforeSessionSwitch | (info) => void \| Promise<void> | No | (v1.5.1+) Called by resolveSession() before it clears/switches this app's tokens as a result of a shared-session change. See resolveSession(). | | authorizeAppAccess | (user, { source }) => boolean \| Promise<boolean> | No | (v1.5.3+, source added in v1.5.4) Optional app-specific authorization check run after WSO2 issues a token but before it's stored. Return false to reject a user WSO2 is otherwise willing to authenticate. source is "manual" or "silent" — see [What's new in v1.5.4](#whats-