@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: trueNon-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-nativeThen 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 installBoth 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 firstThat 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 themEvery 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 body — success: 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, onvisibilitychange. 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 hassyncAcrossTabs, 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;
onRefreshFailedis 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
onRequestare merged last, so they can overrideContent-Typeor replaceAuthorizationfor a scheme this transport doesn't know. onRequestruns 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 inAuthTransportErrorif the retry-vs-logout classification matters.onResponseandonErrorcannot 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 mustIt 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: falsein the body. A 401 reads as a dead credential toisRetryable()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 belowUse 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
Hooks — useAuth(), useAccessToken()
Password recovery — openPasswordRecovery(), getPasswordRecoveryUrl() (from useAuth())
Provider — AuthProvider
Engine — SessionManager (framework-free)
Transport — createRestTransport(), AuthTransport, AuthResult, TokenSet, AuthUser
Errors — AuthTransportError, AuthNotSupportedError, isRetryable(), isNetworkError()
Storage — expoSecureStoreAdapter (/expo), rnKeychainAdapter (/rn), createTokenStorage(), StorageAdapter
JWT — decodeJwt(), expiryFromJwt(), jwtToUser()
Expiry — isAccessTokenExpired(), 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:4873Five 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:
docs/INTEGRATION.mddocs/INTEGRATION.html— same content, browsable
See also
@antzsoft/auth-web— same engine, browser lifecyclesample-reactnative-dual-auth— runnable Expo app running this SDK and the WSO2 one side by side, switchable at runtimemock-auth-server— the shared non-SSO API, with a failure switch for watching retry-vs-logout
