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/auth-react-native

v1.0.5

Published

Server-agnostic token lifecycle management for React Native — proactive refresh, foreground catch-up, secure storage. Bring your own auth API.

Readme

@antzsoft/auth-react-native

Token-lifecycle management for React Native. You bring the auth API; the SDK keeps the session alive across app backgrounding, process kills, and dead networks.

It knows nothing about any particular auth server. Point it at your own endpoints by writing a small transport, and it handles the parts that are the same everywhere and easy to get subtly wrong.

Need SSO as well? It is built in — see Both auth modes, one package below. The standalone WSO2 SDKs remain published for apps already using them; this package does not depend on either.

What's new in 1.0.5

A refresh that failed transiently is now retried. Previously it was abandoned.

Reported from the field as "the token is not refreshed when the app sits idle", and reproducible: one 5xx, timeout, or network drop was enough to stop the refresh cycle for the rest of the app's life.

The proactive timer is re-armed only on a successful refresh. The transient-failure path kept the session — correctly, the credential is fine — and then returned without scheduling anything, so nothing was left to try again. The session sat at status: 'authenticated' holding an access token that expired and was never replaced.

On mobile that is worse than it sounds. The old code's comment said the next foreground check would retry, but AppState emits active only on a background→foreground transition — so an app left open on an unlocked phone never produced one. Locking the phone, switching apps, or making any API call would mask it, which is why it looked intermittent and idle-specific.

What happens now

A transient failure schedules a retry, backing off and capped at a minute:

2s → 4s → 8s → 16s → 32s → 60s → 60s → 60s → …

The cap is the important part. Uncapped doubling would put the 34th attempt — where a 30-minute outage lands — about 286,000 minutes out. Capped, the next attempt is never more than 60 seconds away regardless of how long the outage ran, so recovery after the network returns does not depend on its duration:

| Offline for | Worst-case detection | |---|---| | 1 min | ≤ 32s | | 30 min | ≤ 60s | | 24 hours | ≤ 60s |

retryOnReconnect (default true) makes it immediate rather than ≤60s, using @react-native-community/netinfo — an optional peer. Without it installed the listener is inert and the backoff still recovers the session; the timer is the guarantee, and a connectivity event only short-cuts the wait. That split is deliberate: NetInfo reports link state, and a device can be "connected" to a captive portal with no usable route.

Two behaviour changes to know about

onRefreshFailed now fires once per attempt, so during a sustained outage expect roughly one a minute rather than a single call. The contract has not changed — it always carried an incrementing attempt, and always fired once per attempt. What changed is that there is now more than one attempt. Filter on attempt rather than logging every call to a crash reporter:

onRefreshFailed: ({ attempt }) => {
  if (attempt < 3) return;          // 1-2 is usually a blip
  showReconnectingBanner();         // by 3 it is a real outage
},

A stalled session still reports authenticated. That is correct — the tokens are valid and the server is unreachable — but it means an offline app looks healthy unless you use onRefreshFailed. isRefreshFailing() is also available on the manager if you need to ask rather than be told.

Nothing else changed. A 4xx on refresh still ends the session immediately with no retry: a rejected refresh token is dead, and retrying it would only delay the login screen the user needs to see.

What's new in 1.0.4

Version parity only — no API change on React Native.

1.0.4 restores a web-only callback (useSharedSessionChanged()) and documents two web-only SSO options. None of them apply here: App-Native Authentication posts credentials from your own screens, so there is no shared browser session between apps and nothing that can change underneath you.

Both packages are versioned together so a project pinning one version across web and mobile stays consistent. Upgrading from 1.0.3 is a version bump.

For what actually changed for mobile, see What's new in 1.0.3 below — verifyCredentials(), sendChangePasswordOtp(), the reworked expiry warnings, and the cold-start fixes.

What's new in 1.0.3

Expiry warnings, reworked — one API for both auth modes.

The refresh token's expiry is when a session ends for good. Warning about it used to mean two overlapping options, one of which only worked in SSO. Now there is one:

config={{
  enableExpiryCheck: true,
  expiryWarningThresholds: [86_400, 3_600, 300],   // a day, an hour, 5 min
  onExpiryWarning: ({ threshold, secondsRemaining }) => { ... },
}}

Pass several thresholds to warn more than once as expiry approaches; the callback is told which one fired, so a single handler can escalate from a quiet banner to a blocking prompt. Each fires once per session, and one already passed when the app opens fires on startup — so an app closed through the moment still gets its warning — and a threshold crossed while backgrounded is delivered on the next foreground.

This works in SSO now. The SSO transport never carried refreshExpiresAt through to the engine, so the check was silently inert in that mode. It is fetched from the provider when — and only when — enableExpiryCheck is on, since it costs one extra call per refresh.

Fixed: the chosen auth mode did not survive a cold start. Killing the app and reopening it always came back in the default mode, however the user had left it — switching modes then revealed they were still signed in, which made the loss look like a rendering bug rather than a lost setting.

loadMode() marked the store loaded even when no storage adapter had been configured yet. An app that called it before mounting <AuthProvider> — a reasonable thing to do, to avoid rendering the wrong mode first — got a silent no-op that latched, so the provider's own read early-returned and the persisted value was never seen.

loadMode() now returns without latching when there is nothing to read from, and the React Native sample no longer gates the provider on it: the adapter arrives with the provider, so blocking on loadMode() first is a deadlock. Gate on useModeLoaded() inside the tree instead, which is what the sample now does. The web package was never affected — it reads localStorage synchronously, with no adapter to wait for.

sendChangePasswordOtp() — the step before an OTP-protected change. Also lost in the move to the dual-mode package, which left the OTP flow unable to start: changePassword({ otp }) was reachable but nothing could request the code. Back on useAuth().

SSO only — capabilities.sendChangePasswordOtp is false in non-SSO, where your own API handles any second factor inside its change-password endpoint. See Changing a password with an OTP below.

Dead callbacks removed from the SSO config. onSessionExpired — and on mobile onTokenRefreshed — were declared on the sso prop as well as config. The sso copies never fired: the code that called them belonged to the standalone WSO2 SDK's own React bindings, which this package replaces. An app that set them there got silence.

They now exist only on config, where they fire in both auth modes, so passing one to sso is a type error rather than a quiet no-op. The unreachable bindings themselves are gone too — a 475-line context module that shipped in every install and could not be reached from any import path.

verifyCredentials() — re-check a password without starting a session. For a re-authentication gate ahead of a sensitive change. Previously RN-only and SSO-only; now on useAuth() in both packages and available in either auth mode:

const { valid, reason } = await verifyCredentials({ password });

Non-SSO reaches it through a new verifyCredentials endpoint on the transport — point it at a dedicated verify route, not your login one. In SSO it runs the App-Native flow to completion but never exchanges the authorization code, so the password is proven and no tokens are issued. See Re-checking a password below.

Reading token expiry — no getSessionInfo() needed. The WSO2 SDK exposed getSessionInfo() on useAuth() to fetch the server's expiry timestamps. This package does not, because both values are already on the token set and work the same way in either mode:

const { tokens } = useAuth();
tokens.expiresAt         // access token  — always present
tokens.refreshExpiresAt  // refresh token — SSO needs enableExpiryCheck: true

Non-SSO gets refreshExpiresAt free with the token response. SSO must ask the provider for it, so that lookup is gated behind enableExpiryCheck and apps that never read the value pay nothing. See Reading token expiry below.

Removed: enableDailyExpiryCheck, onDailyExpiryWarning, dailyCheckHour, dailyCheckMinute and expiryWarningWindowSeconds are gone from config. They are gone from the sso prop too — an earlier build kept them there for parity with the copied WSO2 client, but nothing read them, so passing one is now a type error in either place rather than a silent no-op.

Three AuthService methods went with them: checkRefreshTokenExpiringSoon(), getLastDailyCheck() and setLastDailyCheck(). They existed to drive the daily scheduler and have no role now that thresholds are computed from the token's own expiry. If you called them directly, use getRefreshTokenExpiresAt() — or better, read tokens.refreshExpiresAt off the token set and let onExpiryWarning fire.

Migrating:

- enableDailyExpiryCheck: true,
- dailyCheckHour: 5,
- expiryWarningWindowSeconds: 86_400,
- onDailyExpiryWarning: () => banner('Session ends today'),
+ enableExpiryCheck: true,
+ expiryWarningThresholds: [86_400],
+ onExpiryWarning: () => banner('Session ends today'),

The old daily check ran at a fixed hour; thresholds fire relative to the token's own expiry, so there is no clock to configure.

What's new in 1.0.1

Password recovery works again. capabilities.passwordRecovery reported true, but there was no way to act on it: the modal that runs the provider's hosted recovery page was never rendered by the provider, so the capability was unreachable. Two methods now come off useAuth():

const { capabilities, openPasswordRecovery, getPasswordRecoveryUrl } = useAuth();

const { completed } = await openPasswordRecovery();

openPasswordRecovery() opens the page in a WebView modal and resolves { completed }; getPasswordRecoveryUrl() returns the URL for apps that would rather open it themselves. Both reject in non-SSO mode, where your own API owns recovery. See Password recovery below.

theme reaches the provider. It existed on the SSO config but nothing consumed it, since the modal it styles was never mounted. It now sits on the AuthConfig you pass to <AuthProvider> and controls that modal's toolbar colour, title, and close label.

Foreground checks no longer overlap. A slow refresh could be outlived by the next background→active cycle, stacking concurrent checks. One is now in flight at a time, and a failed check can no longer surface as an unhandled rejection — which React Native shows as a redbox in dev and a silent crash report in production.

Fewer redundant renders. Re-asserting unchanged state used to publish a new object and re-render every consumer. State updates are now skipped when nothing actually changed.

Expiry warnings, reworked. One option covers both auth modes: enableExpiryCheck plus expiryWarningThresholds in config. Pass several thresholds — [86_400, 3_600, 300] — and onExpiryWarning is told which one fired, so a single handler can escalate. The SSO-only daily variants are superseded; see Expiry warnings below.

Also fixed in the sibling web package (SSO sign-in completion, hydration safety on server-rendered routes, mode persistence across a switch). Mobile SSO uses App-Native Authentication with no browser redirect, so none of those applied here.

What it does for you

| When | What happens | |---|---| | Access token nears expiry | Refreshes proactively, before any request is rejected | | Two refreshes race | A single-flight lock shares one result — no double-spent rotating token | | App returns to foreground | Catches up on a refresh the timer never got to fire | | App was killed and relaunched | Restores the session from secure storage and refreshes if stale | | Device is offline at cold launch | Comes up authenticated with the stored session, and refreshes once connectivity returns | | Refresh token is genuinely dead | Ends the session and calls onSessionExpired |

Two of these deserve a second look.

Foreground catch-up exists because setTimeout does not survive a process kill on mobile. A user who backgrounds the app for an hour comes back to an expired token with no timer pending to fix it. Checking on every foreground transition covers that gap.

The offline cold launch is the one that bites hardest. If a failed refresh is read as a dead credential, every launch without a network dumps the user at a login screen even though their session is perfectly valid. The rule lives in one place — isRetryable — so it can't drift: a network error, a timeout, or a 5xx keeps the session; a 4xx ends it.

Install

npm install @antzsoft/auth-react-native

Then one storage backend — the SDK never bundles either, so you only ship what you use:

npx expo install expo-secure-store      # Expo managed or bare
# or
npm install react-native-keychain       # bare RN CLI
cd ios && pod install

Both auth modes, one package

This SDK ships non-SSO and SSO together. There is no second package to install and no dependency on one: the SSO implementation lives inside this package, carried over verbatim so its behaviour is unchanged.

<AuthProvider
  transport={transport}        // non-SSO — your own API
  sso={ssoConfig}              // SSO — an OAuth2/OIDC provider
  defaultMode="direct"         // where a first visit starts
  config={{ storageKeyPrefix: 'myapp' }}
>

Supply either, or both. Supplying both gives you a runtime switch:

const { mode, switchMode, canSwitchTo, isSwitching, capabilities } = useAuth();

await switchMode('sso');   // logs out of the current mode first

That logout is not optional. Leaving a live session behind means the outgoing mode keeps refreshing tokens for a user who has apparently signed out, and switching back silently restores a session they thought they had ended. In SSO's case it also leaves the shared provider session alive for every other app in the browser.

Each mode gets its own storage namespace beneath your prefix, so neither can read the other's tokens.

One engine, two modes

The two modes differ only in how tokens are obtained. Once they exist, the same SessionManager drives both — the proactive timer, single-flight refresh lock, retry policy, storage and cross-tab sync are shared, with no branching inside the engine.

That is achieved by wrapping the SSO client so it satisfies the same AuthTransport interface a non-SSO transport implements.

What differs, and how to handle it

login() is the one genuine asymmetry:

| | Non-SSO | SSO (web) | SSO (mobile) | |---|---|---|---| | login() | takes credentials, resolves | takes nothing, navigates away | takes credentials, resolves | | Callback route | no | yes | no | | Focus / foreground recheck | refreshes an expiring token | same, plus a cross-app silent check when signed out | same as non-SSO — no shared session |

Screens should ask capabilities, not the mode:

capabilities.credentialLogin
  ? <form>…</form>                      // credentials collected here
  : <button>Continue with SSO</button>  // provider collects them

Every flag, and what each mode reports:

| Capability | SSO (App-Native) | Non-SSO | |---|---|---| | credentialLogin | true — credentials go to the provider's API | true when the transport has login | | changePassword | true | true when the transport has changePassword | | verifyCredentials | true — runs the flow without exchanging the code | true when the transport has verifyCredentials | | sendChangePasswordOtp | true — the provider sends it | false — your endpoint owns any second factor | | passwordRecovery | true — the provider hosts the page | false — your own API owns it | | usesRedirectCallback | false — App-Native needs no callback route | false |

Mobile SSO uses the provider's App-Native flow — no browser, no redirect — which is why it takes credentials like the non-SSO side.

Usage

1. Describe your API

// auth/transport.ts
import { createRestTransport, expiryFromJwt, type AuthResult } from '@antzsoft/auth-react-native';

interface ServerTokens {
  token: string;
  refreshToken: string;
  user?: { id: string; email: string; name: string };
}

// The ONLY place your server's field names appear.
function mapTokens(res: ServerTokens): AuthResult {
  const expiresAt = expiryFromJwt(res.token);
  if (expiresAt === null) throw new Error('Token has no readable expiry');

  return {
    tokens: {
      accessToken:      res.token,          // your name → the SDK's
      refreshToken:     res.refreshToken,
      expiresAt,                            // epoch MILLISECONDS
      refreshExpiresAt: expiryFromJwt(res.refreshToken) ?? undefined,
    },
    user: res.user
      ? { sub: res.user.id, email: res.user.email, givenName: res.user.name }
      : null,
  };
}

export const transport = createRestTransport({
  baseUrl: 'https://api.example.com',

  login:   { path: '/api/auth/login', map: (r) => mapTokens(r as ServerTokens) },
  refresh: {
    path: '/api/auth/token',
    body: (refreshToken) => ({ refreshToken }),
    // user: undefined → the SDK keeps the user it already knows, instead of
    // blanking your header if refresh returns a thinner object than login did.
    map: (r) => mapTokens({ ...(r as ServerTokens), user: undefined }),
  },
  logout:          { path: '/api/auth/logout' },
  validateSession: { path: '/api/auth/me', method: 'GET' },   // optional
  changePassword:  { path: '/api/auth/change-password' },     // optional
});

The one hard rule: expiresAt is epoch milliseconds. A JWT's exp is in seconds — use expiryFromJwt() rather than multiplying by hand.

Every endpoint takes an optional method ('GET' | 'POST' | 'PUT'); defaults are GET for validateSession and POST for the rest.

When success isn't the HTTP status

Some APIs answer HTTP 200 with a failure flag in the bodysuccess: false, status: "error", a non-zero errorCode. Read the status alone and a failed login looks like a successful one that stored nothing usable.

login: {
  path: '/api/v2/auth/login',
  isSuccess: (body) => (body as any).success === true,
  errorStatus: 401,
  map: mapTokens,
},

errorStatus is the part that's easy to miss. isRetryable() decides keep-vs-end-session from the status, and a bare 200 classifies as transient — so without it, bad credentials would be retried forever instead of ending the session. Default 401.

It can also vary by body:

errorStatus: (body) => ((body as any).rateLimited ? 429 : 401),

Opaque (non-JWT) refresh tokens

Nothing in the engine decodes a token, so an opaque refresh token needs no special handling — read the expiry from whatever field the server sends:

map: (res: any) => ({
  tokens: {
    accessToken:      res.token,
    refreshToken:     res.refresh_token,        // opaque — never decoded
    expiresAt:        res.token_expiry * 1000,        // epoch SECONDS → ms
    refreshExpiresAt: res.refresh_token_expiry * 1000,
    meta: { session_id: res.session_id },       // carried, never read
  },
  user: { sub: String(res.user.user_id) },
}),

expiryFromJwt() is a convenience for JWT access tokens, not a requirement. Both token styles are first-class.

2. Mount the provider

import { AuthProvider } from '@antzsoft/auth-react-native';
import { transport } from './auth/transport';

<AuthProvider
  transport={transport}
  config={{
    storageAdapter: expoSecureStoreAdapter,
    refreshBufferSeconds: 60,       // refresh this long before expiry
    refreshOnForeground: true,      // recheck when the app returns to foreground

    // ── React to session events ────────────────────────────────────────
    onSessionExpired:  () => navigateToLogin(),
    onTokenRefreshed:  (tokens) => socket.reauth(tokens.accessToken),
    onRefreshFailed:   ({ attempt }) => { if (attempt >= 2) showBanner('Reconnecting…'); },
    onExpiryWarning:   () => toast('Your session ends soon'),
  }}
>
  <App />
</AuthProvider>

3. Use it

const {
  status, user, tokens, error,
  login, logout, getAccessToken, refresh,
  changePassword, clearError, isAccessTokenExpired, setTokens,
} = useAuth();

Gate on status. idle and loading are distinct from unauthenticated — treating them as logged-out flashes the login screen on every launch:

if (status === 'idle' || status === 'loading') return <Splash />;
if (status === 'unauthenticated') return <LoginScreen />;
return <Dashboard />;

Log in. The result arrives via state, not the return value:

setBusy(true);
try {
  await login({ username, password });   // credentials pass through untouched
} catch (err) {
  showError((err as Error).message);     // also available as `error`
} finally {
  setBusy(false);
}

Call your API. Always getAccessToken(), never tokens.accessToken — the latter is a render snapshot that may already be seconds from rejection:

const token = await getAccessToken();     // refreshes first if near expiry
await fetch('/api/orders', { headers: { Authorization: `Bearer ${token}` } });

Change a password. Ends the session by default — see below:

await changePassword({ currentPassword, newPassword });

Log out. Clears local state even if the server call fails:

await logout();

Adopt tokens minted elsewhere (SSR bootstrap, a native bridge, a deep link):

await setTokens({ accessToken, refreshToken, expiresAt }, user);

What you call vs. what the SDK calls

Only three of your calls hit the network directly. The rest is automatic:

| Call | Triggered by | |---|---| | login · logout · changePassword | You, from the UI | | getAccessToken | You — may trigger a refresh underneath | | refresh | The SDK: proactive timer, app foreground, restore-on-mount, after a failed validateSession | | validateSession | The SDK: the revocation poll |

That asymmetry is why the callbacks matter — most refreshes have no await of yours to wrap.

What happens when the app returns to the foreground

refreshOnForeground (default true) works identically in both auth modes — it belongs to the engine, not to SSO. No screen remount or navigation reset is involved; state updates in place.

When AppState becomes active the SDK runs the same check its own timer uses:

| Situation | What happens | Cost | |---|---|---| | Access token still outside the refresh buffer | nothing | none — an in-memory expiry comparison | | Access token inside the buffer, or expired | one refresh | one request | | No stored tokens | nothing — signed out is signed out | none |

So a device left in a pocket overnight comes back with a valid token before the first screen makes a call, rather than that call failing and retrying. A failure here is not fatal: the session is untouched and the proactive timer retries.

There is no throttle, and none is needed — AppState fires once per foreground transition, unlike a browser's visibilitychange.

The web package spells this revalidateOnFocus, on visibilitychange. It additionally re-runs a cross-app silent check when it holds no local tokens, because browsers share one provider session across every app on an origin. That has no counterpart here: App-Native Authentication posts credentials from your own screens, so nothing is shared between apps and there is nothing to re-check. Web also has syncAcrossTabs, which is meaningless on a device with one app instance.

Reacting to what the SDK does on its own

| Callback | Fires when | Typical use | |---|---|---| | onTokenRefreshed(tokens) | Every successful refresh | Push the new token to a socket client or a plain fetch module that can't observe React state | | onRefreshFailed({ error, attempt }) | A refresh failed but the session is kept (network, timeout, 5xx). The SDK retries on a backoff capped at 60s, so this fires again on each attempt until it recovers | Show "reconnecting…". attempt counts consecutive failures — stay quiet on the first one or two, and filter before sending these to a crash reporter | | onSessionExpired() | The refresh token is dead, or logout() ran | Route to login. Not called on a first visit with no stored session | | onExpiryWarning({ threshold, secondsRemaining, expiresAt, tokens }) | Each expiry threshold is crossed | Warn before a hard session end. threshold says which one fired, so one handler can escalate. Needs enableExpiryCheck |

Without onRefreshFailed a transient failure is invisible: status stays authenticated and nothing else fires, so a stalled session looks identical to a healthy one.

What the SDK does when a refresh fails

The split is the retry rule, and it decides everything:

| Failure | Session | What the SDK does next | |---|---|---| | 4xx (401, 400, 403…) | ended | Nothing. The refresh token is dead; retrying it would only delay the login screen. onSessionExpired fires | | 5xx, 429 | kept | Retries on a backoff capped at 60s, until it succeeds | | Timeout, no network | kept | Same | | Unrecognised error shape | kept | Same — the SDK will not log anyone out on a guess |

While it is retrying, the session stays authenticated on purpose: the tokens are valid, the server is unreachable, and the two are different problems. Once a retry succeeds the failure count resets and the normal proactive timer takes over again, with no user action.

Two consequences worth designing for:

  • If the outage outlives the access token, real API calls will start getting 401s from your own server — the SDK is holding a token it cannot yet replace. Nothing client-side can prevent that; onRefreshFailed is your cue to show an offline state rather than let requests fail silently.
  • If the outage outlives the refresh token, the next attempt gets a 4xx and the session ends cleanly. That is the honest outcome: the session really did expire.

Writing a transport by hand

createRestTransport covers JSON-over-HTTP. Anything else — a signed header, a two-step login, a non-JSON body — is a sign to write the object directly. It's four methods, and dropping down to it is expected rather than exceptional:

import { AuthTransportError, type AuthTransport } from '@antzsoft/auth-react-native';

export const transport: AuthTransport = {
  // The only required method.
  async refresh(refreshToken) {
    const res = await fetch('https://api.myapp.com/api/auth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken }),
    });
    // Throw AuthTransportError so the status survives — that status is what
    // decides retry vs. logout.
    if (!res.ok) throw new AuthTransportError(res.status, await res.text());
    const d = await res.json();
    return {
      tokens: { accessToken: d.token, refreshToken: d.refreshToken, expiresAt: d.expiresAt },
      user: null,
    };
  },

  async login(credentials) { /* ... */ },
  async logout(tokens)     { /* ... */ },
  // validateSession is optional — omit it and the revocation poll never starts.
};

One hard requirement: expiresAt must be epoch milliseconds. A JWT's exp claim is in seconds — use expiryFromJwt() rather than multiplying by hand. Getting this wrong makes tokens appear to expire in 1970 and triggers an immediate refresh loop.

Hooks around every request

createRestTransport takes three optional observers, so cross-cutting concerns live in one place instead of being copied into each endpoint.

createRestTransport({
  baseUrl,
  login:   { path: '/api/auth/login',  map: mapTokens },
  refresh: { path: '/api/auth/token',  map: mapTokens },

  // Before every request. Return a partial RequestInit to merge in.
  onRequest: async ({ operation, url, method, body }) => {
    showSpinner(operation === 'login');
    return { headers: { 'X-Trace-Id': crypto.randomUUID() } };
  },

  // After every response — success or failure.
  onResponse: ({ operation, status, ok, durationMs }) => {
    metrics.timing(`auth.${operation}`, durationMs, { status });
  },

  // On any failure: non-2xx, network error, or timeout.
  onError: ({ operation, status, error }) => {
    reportToSentry(error, { operation, status });
  },
});

operation is one of login | refresh | logout | validateSession | changePassword, so one hook can branch per call.

Three behaviours worth knowing:

  • Headers from onRequest are merged last, so they can override Content-Type or replace Authorization for a scheme this transport doesn't know.
  • onRequest runs before the timeout is armed. Real work there — minting a signature, awaiting device attestation — doesn't eat the request's budget. Throwing aborts the request; wrap it in AuthTransportError if the retry-vs-logout classification matters.
  • onResponse and onError cannot change the outcome. Anything they throw is swallowed, so a typo in a logging callback can't replace your real 401.

For logic that isn't per-request — anything around the session rather than a single call — use the provider callbacks instead: onSessionExpired, onTokenRefreshed, onExpiryWarning.

Changing a password

Optional. Implement changePassword on your transport and the SDK handles what happens afterwards:

createRestTransport({
  // ...
  changePassword: { path: '/api/auth/change-password' },
});
const { changePassword } = useAuth();
await changePassword({ currentPassword, newPassword });

On success the session ends by default, firing onSessionExpired. That is not tidiness: most servers invalidate the refresh token when the password changes, so the app is already holding a dead credential. Without the teardown the UI keeps working until the next refresh fires, then bounces the user with no explanation — a confusing delayed failure instead of an expected one.

If you have checked that your server keeps existing tokens valid, opt out:

config={{ endSessionOnPasswordChange: false }}

On failure nothing changes. The error is rethrown untouched and the session is left alone — the password wasn't changed, so the session is still valid. Throw AuthTransportError with a code so a form can tell "wrong current password" from "new password too weak".

Configuration

storageAdapter is required; everything else is optional.

| Option | Default | What it does | |---|---|---| | storageAdapter | (required) | expoSecureStoreAdapter, rnKeychainAdapter, or your own | | storageKey | antz_auth_token_set | Change it to run two independent sessions in one app | | refreshBufferSeconds | 60 | How early to refresh. Must be well under your token's lifetime | | refreshOnForeground | true | Catch-up check when the app becomes active — both modes. Web calls this revalidateOnFocus | | retryOnReconnect | true | Retry a stalled refresh the moment connectivity returns. Needs the optional @react-native-community/netinfo peer; inert without it, and the capped backoff still recovers the session | | sessionPollIntervalSeconds | 0 (off) | Poll validateSession to catch server-side revocation. Paused while backgrounded | | enableExpiryCheck | false | Warn before the refresh token's hard expiry | | expiryWarningThresholds | [86400] | Seconds before expiry to warn. Pass several to warn more than once | | onSessionExpired | — | Session ended; send the user to login | | onTokenRefreshed | — | Every successful refresh — push the token to a socket client or plain fetch module | | onRefreshFailed | — | A refresh failed but the session is kept. { error, attempt } — show "reconnecting…". Fires once per retry attempt, so about once a minute during a sustained outage; filter on attempt | | onExpiryWarning | — | Fires inside the expiry window | | endSessionOnPasswordChange | true | Log out after a successful changePassword() — see above | | theme | — | Styles the built-in password-recovery modal (SSO only) — see below |

SSO-side options

Provider options go in the sso prop, not config. Everything the standalone WSO2 SDK accepted still works — it moved from that client's constructor to here.

Required: baseUrl, tenantDomain, clientId, redirectUri.

tenantDomain has no default — every request is issued against /t/<tenantDomain>, so SSO will not work without it. Pass the tenant handle, not a full URL.

| Option | Default | What it does | |---|---|---| | tenantDomain | required | Tenant handle — "dev", "uat", "prod". Becomes the /t/<handle> path prefix on every provider call | | scopes | ["openid", "profile", "email", "phone"] | OAuth2 scopes requested during the App-Native flow | | theme | — | Colours for the built-in password-recovery WebView modal, the only UI this package renders | | storageAdapter | — | Where tokens are kept. Use the /expo or /rn entry point rather than writing one | | storageKey | antz_auth_tokens | Rename to keep two apps on one device from sharing a session | | refreshBufferSeconds, sessionPollIntervalSeconds | inherited | Same meaning as in config; set here only to differ in SSO mode |

sso={{
  baseUrl: 'https://auth.antzsystems.com',
  tenantDomain: 'dev',
  clientId,
  redirectUri: 'antzmobile://auth/callback',
}}

redirectUri is never actually followed in the App-Native flow — the provider returns tokens to the app directly — but WSO2 still validates that it is present and registered, so it must match the Console exactly.

There is no proxyUrl here, and none is needed: a native app makes no cross-origin browser request, so WSO2's REST APIs accept its calls directly.

Password recovery (SSO only)

Recovery is a page the provider hosts, so the SDK opens it in a WebView modal rather than exposing an endpoint. Gate it on the capability, not the mode:

const { capabilities, openPasswordRecovery } = useAuth();

{capabilities.passwordRecovery && (
  <Pressable onPress={async () => {
    const { completed } = await openPasswordRecovery();
    if (completed) Alert.alert('Password changed', 'Sign in with your new password.');
  }}>
    <Text>Forgot password?</Text>
  </Pressable>
)}

Resolves { completed: true } when the flow finished, { completed: false } if dismissed. Rejects in non-SSO mode — your own API owns recovery there. getPasswordRecoveryUrl() returns the URL if you would rather open it yourself.

theme styles this modal, and is the only styling hook the SDK exposes:

config={{
  storageAdapter: expoSecureStoreAdapter,
  theme: { primaryColor: '#8a5a1c', recoveryTitle: 'Reset password', closeLabel: 'Close' },
}}

React Native only — the web package renders no built-in UI.

Which mode is active, and remembering it

The chosen mode is persisted through your storageAdapter, so an app reopens in whatever the user last selected. Reading it is asynchronous on mobile — secure storage always is — which gives a third state web does not have: not known yet.

function Root() {
  const { status } = useAuth();
  const modeLoaded = useModeLoaded();

  // `modeLoaded` is false only for the first moment after launch.
  if (!modeLoaded || status === 'idle' || status === 'loading') return <Splash />;

  return status === 'authenticated' ? <Dashboard /> : <LoginScreen />;
}

Render a splash for that moment rather than guessing. Treating "not known yet" as the default mode makes a user whose stored mode is SSO see the non-SSO login screen flash on every launch.

Do not call loadMode() yourself, and do not gate <AuthProvider> on it. The provider calls it once it has configured the store with your adapter. Calling it earlier cannot work — there is nothing to read from yet — and blocking the provider until it resolves is a deadlock: the adapter arrives with the provider.

// ✗ Deadlock — loadMode() can never read anything, and the persisted
//   mode is lost on every launch.
const [ready, setReady] = useState(false);
useEffect(() => { void loadMode().then(() => setReady(true)); }, []);
if (!ready) return <Splash />;
return <AuthProvider …>…</AuthProvider>;

// ✓ Mount the provider, then gate inside it.
return (
  <AuthProvider config={{ storageAdapter }} …>
    <Root />          {/* uses useModeLoaded() */}
  </AuthProvider>
);

loadMode() is exported for tests and for apps that manage the provider themselves; it is idempotent and a no-op once the mode has been read.

Outside React — a navigation guard, a plain module — readMode() returns the current mode synchronously. It reports the default until loadMode() has finished, so pair it with useModeLoaded() anywhere the distinction matters.

Changing a password with an OTP

When the provider protects password changes with a one-time code, request it first — changePassword({ otp }) has nothing to consume otherwise:

const { sendChangePasswordOtp, changePassword, capabilities } = useAuth();

if (capabilities.sendChangePasswordOtp) {
  await sendChangePasswordOtp();          // provider sends the code
  const otp = await promptForOtp();
  await changePassword({ currentPassword, newPassword, otp });
} else {
  await changePassword({ currentPassword, newPassword });
}

SSO only. The provider owns OTP delivery, so capabilities.sendChangePasswordOtp is false in non-SSO and the call rejects there. That is not a gap: in non-SSO your own API decides whether a change needs a second factor, and handles it inside your changePassword endpoint rather than as a separate round trip.

Needs a live session — the provider identifies the user from the access token.

Request timeout

Every request this transport makes is aborted after timeoutMs (default 15000):

createRestTransport({ baseUrl, login, refresh, timeoutMs: 20_000 })

This matters more than it looks. Without a timeout, a refresh on a stalled network can hang indefinitely and every queued API call waits behind it. The abort surfaces as an AbortError, which isRetryable() treats as transient — so the session survives and the next attempt retries, rather than the user being signed out because their train went into a tunnel.

Headers

Two ways, and they compose.

Static — on every request. For values fixed at startup: an API key, a client identifier, a tenant.

createRestTransport({
  baseUrl,
  headers: {
    'X-Client-Platform': 'android',
    'X-Client-App': 'antz-mobile',
  },
  refresh: { ... },
});

Dynamic — per request. onRequest runs before every call and returns a partial fetch init to merge. It knows which operation is running, so headers can vary by endpoint:

onRequest: ({ operation }) => ({
  headers: {
    'X-Request-Id': crypto.randomUUID(),   // changes every call
    'X-Zooid': currentZooid(),             // changes at runtime
    ...(operation === 'refresh' ? { 'X-Retry': '1' } : {}),
  },
}),

They merge in this order, each overriding the one above:

| | Source | |---|---| | 1 | Content-Type: application/json | | 2 | your static headers | | 3 | Authorization: Bearer … (the SDK) | | 4 | onRequest headers |

onRequest last is deliberate: it can replace Authorization outright if your API uses a scheme this transport doesn't know.

onRequest: () => ({ headers: { Authorization: `MyScheme ${customToken()}` } }),

It can also return anything else fetch accepts — credentials: 'include' for cookie-based endpoints, a custom mode, and so on. And throwing from it aborts the request before fetch runs, which is where a signing or attestation step belongs:

onRequest: async () => {
  await ensureDeviceAttested();          // throws → the request never leaves
  return { headers: { 'X-Device': deviceId } };
},

There is no per-endpoint headers field. Branch on operation inside onRequest instead — one place to look rather than five.

Re-checking a password

Some flows ask someone to prove who they are before a sensitive change — a forgotten passcode, a password change. verifyCredentials() answers "is this password correct" without starting a session:

const { verifyCredentials, capabilities } = useAuth();

if (capabilities.verifyCredentials) {
  const { valid, reason, requiresAdditionalSteps } = await verifyCredentials({
    password,
  });

  if (valid) proceed();
  else if (requiresAdditionalSteps) askForOtp();   // password right, MFA needed
  else showError(reason);
}

A wrong password resolves { valid: false } — it does not throw, and it does not disturb the current session. Only genuine failures (network, 5xx) reject.

Pass only the password. The SDK fills in the signed-in user's identity, which the two modes need differently: SSO's App-Native flow submits a username and password to the provider, while a non-SSO verify endpoint identifies the user from the bearer and takes only the password. One call site works in both.

await verifyCredentials({ password });              // usual case
await verifyCredentials({ username, password });    // override if you must

It uses user.username, falling back to user.email. If the session carries neither, SSO returns { valid: false } with a reason telling you to pass one explicitly. In SSO mode this uses the App-Native flow: the login runs to completion but the resulting authorization code is never exchanged, so the password is proven and no tokens are issued. capabilities.verifyCredentials is true in SSO here — unlike web, where the provider owns the login page.

Non-SSO — point it at a dedicated endpoint, not your login route. Logging in again mints a session that then has to be torn down, and with rotating refresh tokens a failed teardown leaves an orphan behind.

verifyCredentials: {
  path: '/api/v2/auth/verify-password',
  body: (creds) => ({ password: (creds as { password: string }).password }),
  map: (res) => {
    const r = res as { valid?: boolean; message?: string };
    return { valid: r.valid === true, reason: r.valid ? undefined : r.message };
  },
},

Two things the endpoint should do, and both are security properties:

  • Answer 200 for a wrong password, with valid: false in the body. A 401 reads as a dead credential to isRetryable() and would end the very session the user is re-authenticating within.
  • Be authenticated, so the body carries only the password. An unauthenticated variant taking an email lets anyone probe any account — and it returns no user data, so there is nothing to learn from guessing.

It also wants rate limiting: it accepts password guesses by design, without the protections your login route has.

Reading token expiry

Both expiries are on the token set — no extra call, and the same in either auth mode:

const { tokens } = useAuth();

tokens.expiresAt         // access token,  epoch ms — always present
tokens.refreshExpiresAt  // refresh token, epoch ms — see the caveat below

Use these for a "session ends in N days" line, a countdown, or a debug screen. There is no getSessionInfo() to call: where the value comes from differs by mode, and the SDK normalises it before you see it.

| Mode | Where refreshExpiresAt comes from | |---|---| | Non-SSO | Your map reads it straight off the login/refresh response | | SSO | Fetched from the provider, because the refresh token is opaque and cannot be read locally |

The caveat, and it only affects SSO: refreshExpiresAt is populated in SSO mode only when enableExpiryCheck: true. That lookup costs one extra call per refresh, so apps that never read the value do not pay for it. Without the flag the field is undefined in SSO — and always present in non-SSO, where it arrives free with the token response.

config={{
  enableExpiryCheck: true,   // needed for refreshExpiresAt in SSO mode
}}

expiresAt needs no flag in either mode.

One thing these values are not: a live server read. They are what the last login or refresh reported, which is what you want for display. If a session's expiry can change server-side between refreshes, treat them as a good estimate rather than authoritative.

Errors you can catch

Every error class carries an end-user-safe .message, so showing it directly is always acceptable. Catch a specific class only when you want your own wording or a different action — a "switch account" button, say.

All are exported from the package root:

import {
  AntzSessionUserMismatchError,
  AntzAuthorizeRejectedError,
  AntzAppAccessDeniedError,
  AuthTransportError,
} from '@antzsoft/auth-react-native';

The session-conflict cases. These are exported here so the class list stays identical across both SDKs, but on mobile they behave differently from the web — and mostly do not arise at all:

| Class | On React Native | Useful fields | |---|---|---| | AntzAuthorizeRejectedError | The provider refused the sign-in — typically this user isn't provisioned for this app | wso2Error, wso2ErrorDescription | | AntzSessionUserMismatchError | Does not occur. No shared session exists to mismatch against | — | | AntzAppAccessDeniedError | Does not occur. There is no silent cross-app check on mobile | — |

Why the difference: App-Native Authentication posts the username and password straight from your own screens to the provider, so there is no hosted login page and no commonAuthId cookie shared between apps. Each app authenticates on its own, which removes the "someone else is already signed in on this device" problem the web SDK has to detect — along with the loginHint / prompt=login machinery the web samples use to trigger it.

Catch AntzAuthorizeRejectedError and show its .message; a user with no access to the app is the case that actually reaches you. The two "does not occur" classes are safe to leave unhandled, and safe to keep in a shared catch chain if you have one that targets both platforms.

The rest, mostly self-explanatory: AntzInvalidCredentialsError, AntzSessionExpiredError, AntzPasswordPolicyError, and four separate OTP cases — AntzOtpRequiredError, AntzInvalidOtpError, AntzOtpExpiredError, AntzOtpMaxAttemptsError — so a screen can say which OTP problem occurred.

Both modes: AuthTransportError carries httpStatus, which is what isRetryable() reads — 5xx, network errors and 429 keep the session and retry; 4xx ends it. AuthNotSupportedError means the active mode cannot do what was asked; check capabilities first.

One catch for everything. All SSO error classes extend AntzAuthError, so a single instanceof covers them when you only need to separate "the SDK said no" from a bug in your own code:

import { AntzAuthError } from '@antzsoft/auth-react-native';

try { await login({ username, password }); }
catch (err) {
  if (err instanceof AntzAuthError) setError(err.message);  // safe to display
  else throw err;                                           // not ours — let it surface
}

AntzTokenError (token exchange or refresh failed) and AntzApiError (a provider REST call failed, carrying status) sit under it too, for the rarer case of telling transport-level trouble from an auth decision.

Customizing error messages

Catch the class, read its fields, write your own text:

try {
  await login({ username, password });
} catch (err) {
  if (err instanceof AntzAuthorizeRejectedError) {
    // The realistic case on mobile: authenticated, but not entitled to this app.
    setError(err.wso2ErrorDescription ?? 'You do not have access to this app.');
  } else if (err instanceof AntzInvalidCredentialsError) {
    setError('Wrong username or password.');
  } else if (err instanceof AntzOtpRequiredError) {
    navigation.navigate('Otp');
  } else {
    setError('Sign-in failed. Please try again.');
  }
}

No silent cross-app path on mobile. App-Native Authentication has no shared browser session, so onBeforeSessionSwitch, AntzAppAccessDeniedError, AntzSessionUserMismatchError and the consumeSilentLogoutMessage() flow are web-only. A rejected login surfaces as a thrown error you can catch directly, as above.

Session callbacks

All of these live on config and fire identically in both auth modes — they belong to the engine, which is the same code either way:

config={{
  onSessionExpired: () => router.push('/login'),
  onTokenRefreshed: (tokens) => socket.setToken(tokens.accessToken),
  onRefreshFailed:  ({ error, attempt }) => showBanner(`Reconnecting… (${attempt})`),
  onExpiryWarning:  ({ threshold }) => warnUser(threshold),
}}

| Callback | Fires when | Session | |---|---|---| | onSessionExpired | The session ended and cannot be recovered | gone — send the user to login | | onTokenRefreshed | Every successful refresh | alive — push the new token wherever you cache it | | onRefreshFailed | A refresh failed transiently | kept — show "reconnecting", do not log out | | onExpiryWarning | A configured threshold is crossed | alive — see Expiry warnings |

The split between the middle two is the one to get right: onRefreshFailed means a network blip or a 5xx and the session survives, while onSessionExpired means a dead credential and it does not. Treating the first as a logout signs users out during an outage.

Do not look for these on the sso prop. They used to be declared there too and never fired — the code that called them belonged to the standalone WSO2 SDK's own bindings, which this package replaces. They are gone from that config now, so passing one is a type error rather than silence.

Expiry warnings

The refresh token has its own expiry. When it passes, the session ends and re-login is unavoidable — so the useful thing is advance notice.

Pass several thresholds to warn more than once as expiry approaches. One option, one callback; the callback is told which threshold fired, so a single handler can escalate:

config={{
  enableExpiryCheck: true,
  expiryWarningThresholds: [86_400, 3_600, 300],   // a day, an hour, 5 min

  onExpiryWarning: ({ threshold, secondsRemaining, expiresAt, tokens }) => {
    if (threshold <= 3_600) showModal(`Signing out in ${secondsRemaining}s`);
    else                    showBanner('Your session ends today');
  },
}}

A single number works too — expiryWarningThresholds: 3_600. The default is [86400] (24 hours).

Each threshold fires once per session. A threshold already passed when the app opens fires on startup, so a device closed through the moment still gets the warning — with one exception: a session that began after the threshold is skipped, since a freshly issued token cannot be near expiry.

The SDK does NOT log anyone out. The callback decides whether to prompt, badge, or ignore. Auto-logout on actual expiry is separate and always on, in both modes.

Needs refreshExpiresAt on the token set; silently inert without it. Non-SSO gets it from your map; in SSO the SDK fetches it from the provider — which costs one extra call per refresh, so it only happens when enableExpiryCheck is on.

Storage

Refresh tokens are long-lived credentials. Both bundled adapters are backed by platform keychain APIs:

| Adapter | Backing | Works in | |---|---|---| | expoSecureStoreAdapter | iOS Keychain · Android Keystore | Expo managed, Expo bare, RN CLI with the package installed | | rnKeychainAdapter | iOS Keychain · Android Keystore | Bare RN CLI, Expo bare |

Do not substitute AsyncStorage — it is unencrypted. Implement StorageAdapter yourself for anything else (MMKV, a test double).

One JSON blob under one key. There is no storageKeyPrefix here, unlike the web package: that option exists because browser storage is scoped to the origin, so apps sharing a host collide. Each mobile app is sandboxed with its own keychain namespace, so there is nothing to collide with. Override storageKey only to run two sessions inside one app (multi-account, or dev and prod in one build).

Security

decodeJwt does not verify signatures, and cannot: a mobile client can't hold a verification key safely. Decoded claims are for display and scheduling only. Every authorisation decision belongs on the server.

API reference

HooksuseAuth(), useAccessToken() Password recoveryopenPasswordRecovery(), getPasswordRecoveryUrl() (from useAuth()) ProviderAuthProvider EngineSessionManager (framework-free) TransportcreateRestTransport(), AuthTransport, AuthResult, TokenSet, AuthUser ErrorsAuthTransportError, AuthNotSupportedError, isRetryable(), isNetworkError() StorageexpoSecureStoreAdapter (/expo), rnKeychainAdapter (/rn), createTokenStorage(), StorageAdapter JWTdecodeJwt(), expiryFromJwt(), jwtToUser() ExpiryisAccessTokenExpired(), isRefreshTokenExpired(), msUntilRefresh(), expiresAtFromTtl()

Local development

npm install
npm run build

# Publish to a local Verdaccio registry (see .npmrc)
npx verdaccio &
npm adduser --registry http://localhost:4873
npm publish --registry http://localhost:4873

Five files under src/transport/ and src/utils/ are behaviourally identical with the web package. sdks/scripts/check-shared.sh enforces that, and also greps both packages for provider-specific vocabulary. Run it after touching either.

Full integration guide

Step-by-step, with every option and the reasoning behind the defaults:

See also