Skip to main content

Auth client

info

For the framework-wide authorisation model (permission strings, ABAC, RLS bridge) start at Authentication & Authorisation. This page is the browser-side token-lifecycle reference.

AuthenticationClient (from @omnitron-dev/netron-browser) owns browser-side authentication state: where the token lives, when to refresh, how to propagate sign-in/out across tabs, and when to time out an idle session.

You attach it to a transport client (HTTP or WebSocket) so every RPC call carries the token, and a refresh fires automatically on 401. In React apps it's usually wrapped by netron-react's AuthProvider — but the client works standalone.

Verified against packages/netron-browser/src/auth/.

Wiring

Pass an AuthenticationClient to the transport client. The client attaches the token to every request and the built-in auth-error-handler middleware refreshes + retries on 401:

import { HttpClient } from '@omnitron-dev/netron-browser';
import { AuthenticationClient, LocalTokenStorage } from '@omnitron-dev/netron-browser';
import { createAuthErrorMiddleware } from '@omnitron-dev/netron-browser/middleware';

const auth = new AuthenticationClient({
storage: new LocalTokenStorage('platform:token'),
autoRefresh: true,
refreshThreshold: 5 * 60_000, // refresh 5 min before expiry
refreshConfig: { endpoint: '/auth/refresh' },
inactivityConfig: { timeout: 30 * 60_000 }, // 30 min
crossTabSync: { enabled: true },
});

const client = new HttpClient({ url: 'https://api.example.com', auth });

// Refresh-then-retry on 401 (and surface 403 / 429):
client.use(createAuthErrorMiddleware({
authClient: auth,
onSessionExpired: () => location.assign('/sign-in?reason=expired'),
}));

// On sign-in success (an AuthResult from your authenticate call):
auth.setAuth(result);

// On sign-out:
await auth.logout(); // POSTs logoutConfig.endpoint if set, then clearAuth()

AuthenticationClient is the real class — there is no AuthManager, no setTokens, no clear(). Use setAuth() / clearAuth() / logout() and getToken().

Constructor options

new AuthenticationClient(options: AuthOptions) — all fields optional:

OptionDefaultNotes
storagenew MemoryTokenStorage()A TokenStorage instance (see below). Secure-by-default — memory, not localStorage.
storageKey'netron_auth_token'Convenience: if storage is omitted but storageKey is set, a LocalTokenStorage(storageKey) is created
autoRefreshtrueSchedule a refresh before expiry
refreshThreshold5 * 60_000Refresh this many ms before expiresAt
autoAttachtrueAttach the token to outgoing requests
refreshConfig{ endpoint, method?, headers?, buildBody? }
logoutConfig{ endpoint, method?, headers?, includeToken? }
inactivityConfig{ timeout: 30*60_000, events: ['click','keypress','mousemove'] }Idle auto-sign-out
crossTabSync{ enabled: true, syncKey: 'netron_auth_sync' }Sync sign-in/out across tabs
transportA token transport strategy (Bearer / Cookie / Hybrid — see below)

Storage backends

storage takes a TokenStorage instance (not a string). Four implementations ship from @omnitron-dev/netron-browser:

ClassSurvivesUse case
MemoryTokenStoragenothing (reload clears)Default. Highest security; user re-auths on reload
LocalTokenStorage(key?)tab close + reloadLong-lived sessions; most apps
SessionTokenStorage(key?)tab close (per-tab)"Remember me off"
NoopTokenStorageCookie-mode — the browser holds an HttpOnly cookie, nothing is stored client-side
import { LocalTokenStorage, MemoryTokenStorage, NoopTokenStorage }
from '@omnitron-dev/netron-browser';

new AuthenticationClient({ storage: new LocalTokenStorage('myapp:token') });

The TokenStorage interface is getToken() / setToken() / removeToken() / hasToken() plus generic getValue() / setValue() / removeValue() (the client persists a serialized context alongside the token).

How the token reaches the server is a pluggable IClientTokenTransport (T#176). Pass one as transport:

TransportSendsUse
BearerClientTokenTransportAuthorization: Bearer <token> header (+ ?token= on WS)Default bearer-token model
CookieClientTokenTransportnothing — sets credentials: 'include' so the browser sends the HttpOnly cookieCookie-mode auth (pair with NoopTokenStorage)
HybridClientTokenTransportboth — cookie credentials + bearer headerMigration / dual-mode
import { AuthenticationClient, NoopTokenStorage } from '@omnitron-dev/netron-browser';
import { CookieClientTokenTransport } from '@omnitron-dev/netron-browser';

// HttpOnly-cookie auth: no client-side token, browser sends the cookie.
const auth = new AuthenticationClient({
storage: new NoopTokenStorage(),
transport: new CookieClientTokenTransport(),
});

Token + result shapes

After authenticating, you hand the client an AuthResult (the shape your server's authenticate task returns):

interface AuthResult {
success: boolean;
context?: AuthContext; // user identity + roles/permissions
error?: string;
metadata?: Record<string, any>; // tokens live here: { accessToken, refreshToken, refreshTokenExpiresAt }
}

interface AuthContext {
userId: string;
roles: string[];
permissions: string[];
scopes?: string[];
token?: { type: 'bearer' | 'mac' | 'custom'; expiresAt?: Date; issuer?: string; audience?: string[] };
metadata?: Record<string, any>;
}

setAuth(result) reads the access token from metadata.accessToken and the refresh token from metadata.refreshToken, and uses context.token.expiresAt for proactive refresh. For a bare token (no full result) use setToken(token, context?).

Auto-refresh flow

Proactive (autoRefresh + refreshThreshold) covers clock skew and slow networks; the reactive auth-error-handler middleware covers a refresh that fired mid-request.

Concurrent-request deduplication

refreshToken() coalesces concurrent calls via a single shared refreshPromise — multiple requests that 401 simultaneously share one refresh call rather than all hitting the endpoint.

Cross-tab sync

With crossTabSync: { enabled: true }, sign-in / sign-out in one tab propagates to others via the storage storage event (the client writes a record to crossTabSync.syncKey):

(enableCrossTabSync() / disableCrossTabSync() toggle it at runtime.) Cross-tab auth sync uses storage events; the separate multi-tab WebSocket leader-election feature is unrelated.

Inactivity timeout

new AuthenticationClient({
inactivityConfig: {
timeout: 30 * 60_000,
events: ['click', 'keypress', 'mousemove'], // activity resets the timer
onInactivity: () => { /* optional callback */ },
},
});

When the timeout expires the client emits 'inactivity', calls onInactivity (if given), then clearAuth(). Subscribe to react in your app:

auth.on('inactivity', () => navigate('/sign-in?reason=timeout'));

Event subscriptions

Subscribe with on(event, handler) / unsubscribe with off(event, handler). The six event types:

auth.on('authenticated', ({ context }) => { /* signed in */ });
auth.on('unauthenticated', () => { /* signed out / cleared */ });
auth.on('token-refreshed', ({ context }) => { /* token rotated */ });
auth.on('error', ({ error, context })=> { /* refresh/logout failed */ });
auth.on('inactivity', ({ lastActivity }) => { /* idle timeout */ });
auth.on('cross-tab-sync', ({ type }) => { /* another tab changed auth */ });

React integration

netron-react's AuthProvider wraps the auth lifecycle and exposes useAuth(). Sign-in runs through the provider's onLogin handler; components call login / logout (see the netron-react auth section for the full provider API):

import { AuthProvider, useAuth } from '@omnitron-dev/netron-react/auth';

<AuthProvider
config={{ refreshEndpoint: '/auth/refresh', storage: 'local', autoRefresh: true }}
onLogin={(credentials) => client.invoke('auth', 'signIn', [credentials])}
>
<Outlet />
</AuthProvider>

function UserMenu() {
const { user, isAuthenticated, login, logout } = useAuth();

if (!isAuthenticated) {
return <Button onClick={() => login(credentials)}>Sign in</Button>;
}
return (
<Menu>
<MenuItem disabled>{user?.userId}</MenuItem>
<MenuDivider />
<MenuItem onClick={() => logout()}>Sign out</MenuItem>
</Menu>
);
}

useAuth() returns { isAuthenticated, user, login, logout, refresh, getAuthHeaders, hasRole, hasPermission, hasAnyRole, hasAllRoles }.

Route guards

import { AuthGuard, GuestGuard } from '@omnitron-dev/netron-react/auth';

<Routes>
<Route element={<GuestGuard redirectTo="/"><AuthLayout /></GuestGuard>}>
<Route path="/sign-in" element={<SignInPage />} />
</Route>
<Route element={<AuthGuard redirectTo="/sign-in"><DashboardLayout /></AuthGuard>}>
<Route path="/" element={<Dashboard />} />
</Route>
</Routes>

<AuthGuard> renders children when authenticated, else its fallback (and can redirectTo); <GuestGuard> is the inverse. <RoleGuard role="…"> / <PermissionGuard permission="…"> gate on RBAC.

Role-gated content

import { useAuth } from '@omnitron-dev/netron-react/auth';

function AdminPanel() {
const { hasRole } = useAuth();
if (!hasRole('admin')) return null;
return <DestructiveOperations />;
}

hasRole(role) checks the context's roles; hasAnyRole(roles) / hasAllRoles(roles) cover the array cases.

Sign-in flow with 2FA

async function handleSignIn(values: { email: string; password: string; totpCode?: string }) {
try {
const result = await client.invoke('auth', 'signIn', [values]); // returns AuthResult

if (result.metadata?.requires2fa) {
setPendingMfa(true); // show 2FA input, call signIn again with totpCode
return;
}

auth.setAuth(result); // stores token + context, emits 'authenticated'
navigate('/');
} catch (e) {
form.setError('root', { message: (e as Error).message });
}
}

The two-step flow keeps the 2FA input out of the password form until needed. (Through React, prefer useAuth().login(values) — it runs the provider's onLogin and calls setAuth for you.)

Sign-in flow with WebAuthn / passkey

const challenge = await client.invoke('auth', 'getWebAuthnChallenge', [{ email }]);
const credential = await navigator.credentials.get({ publicKey: challenge });
const result = await client.invoke('auth', 'verifyWebAuthn', [{ credential }]);
auth.setAuth(result);

The client doesn't care about the source — setAuth stores the result the same way regardless of method.

Programmatic token access (advanced)

const token = auth.getToken(); // string | undefined (synchronous)
const isAuth = auth.isAuthenticated();
const context = auth.getContext(); // AuthContext | undefined (userId, roles, …)
const session = auth.getSessionMetadata(); // { sessionId, loginTime, … } | undefined
const headers = auth.getAuthHeaders(); // e.g. { Authorization: 'Bearer …' }

Useful for direct fetch calls outside the RPC client (file uploads, third-party SDKs) — merge getAuthHeaders() into your request.

Custom refresh

refreshConfig shapes the refresh request — use buildBody / headers / method when refresh isn't a plain POST { refreshToken }:

new AuthenticationClient({
refreshConfig: {
endpoint: '/auth/refresh',
method: 'POST',
headers: { 'X-CSRF-Token': readCsrfCookie() },
buildBody: (refreshToken) => JSON.stringify({ refreshToken }),
},
});

Security considerations

  • Default storage is memory — secure-by-default. Opt into LocalTokenStorage only when you need persistence across reloads, and understand that localStorage tokens are reachable by any script on the origin (XSS = token compromise).
  • HttpOnly cookies are immune to XSS but need CSRF protection — use CookieClientTokenTransport + NoopTokenStorage and the csrf middleware (createCsrfMiddleware). Pick one model and stick to it.
  • Don't log tokens. Even at debug level.
  • Inactivity timeout matters for shared / public computers — default 30 min; keep it short for admin surfaces.
  • Token rotation hooks (auth.on('token-refreshed', …)) can surface session rotation in a security dashboard.

Best practices

  • One AuthenticationClient per app, wired before any RPC calls fire.
  • Attach it to the transport (new HttpClient({ url, auth })) so tokens flow automatically; add createAuthErrorMiddleware for refresh-on-401.
  • Use a real expiresAt (via the result's context.token.expiresAt) so autoRefresh fires proactively instead of one-failed-request-per-cycle.
  • crossTabSync: { enabled: true } unless you have a specific reason not to.

Anti-patterns

  • Storing tokens in both cookies and localStorage. Pick one transport model; mixed approaches cause refresh / clear bugs.
  • Reaching for AuthManager / setTokens. Those don't exist — it's AuthenticationClient + setAuth / clearAuth.

See also