Skip to main content

Auth manager

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.

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

It's used by AuthMiddleware to attach tokens to every RPC call and to refresh on 401. The middleware also handles the PERMISSION_VERSION_STALE (401) error introduced in the v2 auth surface — same refresh-then-retry path as TOKEN_EXPIRED; the new JWT carries the up-to-date permission set so live guards re-render correctly without forcing a sign-in. See the migration guide for the wire-level shape.

Wiring

import { AuthManager } from '@omnitron-dev/netron-browser/auth';
import { AuthMiddleware } from '@omnitron-dev/netron-browser/middleware';

const auth = new AuthManager({
storage: 'localStorage',
tokenKey: 'platform:tokens',
refreshEndpoint: '/auth/refresh',
inactivityTimeout: 30 * 60_000, // 30 min
crossTabSync: true,
});

const client = createClient({ url: 'https://api.example.com' });
client.use(AuthMiddleware({ authManager: auth }));

// On sign-in success:
await auth.setTokens({ accessToken, refreshToken, sessionId, expiresAt });

// On sign-out:
await auth.clear();

Options

OptionDefaultNotes
storage'localStorage''localStorage' | 'sessionStorage' | 'memory'
tokenKey'netron:tokens'Storage key
refreshEndpointPath / URL to call on 401
refreshFnCustom refresh function (overrides endpoint)
inactivityTimeout0 (disabled)Auto-sign-out after N ms idle
crossTabSynctrueUse BroadcastChannel to sync sign-in/out across tabs
channelName'netron-auth'BroadcastChannel name
tokenExpiryBuffer30_000Refresh N ms before exp

Storage backends

BackendSurvivesUse case
'localStorage'Tab close + reloadLong-lived sessions; most apps
'sessionStorage'Tab close (per-tab)"Remember me off"
'memory'ReloadHighest security; user re-auths on every refresh

For HttpOnly-cookie auth, set storage: 'memory' (or skip the manager entirely) — the browser handles the cookie.

Token shape

interface AuthTokens {
accessToken: string;
refreshToken?: string;
sessionId?: string;
expiresAt?: number; // epoch ms — used for proactive refresh
user?: Partial<User>; // optional cached profile
}

expiresAt lets the manager refresh proactivelytokenExpiryBuffer ms before expiry — rather than waiting for 401.

Auto-refresh flow

Proactive + reactive — the buffer covers clock skew + slow networks; the 401 handler covers refresh that fired mid-request.

Concurrent-request deduplication

Multiple in-flight requests that 401 simultaneously share one refresh call — they don't all hit the refresh endpoint. The manager queues subsequent calls and resolves them with the refreshed token.

Cross-tab sync

When crossTabSync: true, sign-in / sign-out in one tab propagates to all open tabs via BroadcastChannel:

Falls back to localStorage storage events on browsers without BroadcastChannel.

Inactivity timeout

new AuthManager({
inactivityTimeout: 30 * 60_000,
// ...
});

Resets on:

  • Any user input (mousemove, keydown, click, touchstart, scroll).
  • Any RPC call (proves activity).
  • Cross-tab activity (one active tab keeps all alive).

When the timeout expires:

auth.on('inactivity-timeout', async () => {
await auth.clear();
navigate('/sign-in?reason=timeout');
});

The manager just fires the event; your app decides what to do (sign out, lock screen with PIN unlock, ...).

Event subscriptions

auth.on('sign-in', ({ user, source }) => { /* sync local state */ });
auth.on('sign-out', ({ source }) => { /* redirect */ });
auth.on('refresh', ({ accessToken }) => { /* update header banner */ });
auth.on('refresh-failed', ({ error }) => { /* probably show sign-in */ });
auth.on('inactivity-timeout', () => { /* lock or sign out */ });

source is 'self' for actions originated in this tab, 'remote' for cross-tab broadcasts.

React integration

netron-react's AuthProvider wraps AuthManager and exposes useAuth():

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

<AuthProvider>
<Outlet />
</AuthProvider>

function UserMenu() {
const { user, isAuthenticated, signIn, signOut, refresh } = useAuth();

if (!isAuthenticated) {
return <Button onClick={() => signIn(credentials)}>Sign in</Button>;
}
return (
<Menu>
<MenuItem disabled>{user.email}</MenuItem>
<MenuDivider />
<MenuItem onClick={signOut}>Sign out</MenuItem>
</Menu>
);
}

Route guards

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

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

<AuthGuard> redirects unauthenticated users to /sign-in (configurable); <GuestGuard> redirects authenticated users to / (configurable).

Role-gated content

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

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

hasRole checks user.roles against the argument; supports arrays for "any of":

hasRole(['admin', 'moderator'])

Sign-in flow with 2FA

async function handleSignIn(values: { email: string; password: string; totpCode?: string }) {
try {
const result = await authService.signIn(values);

if (result.requires2fa) {
setPendingMfa(true); // show 2FA input
return;
}

await auth.setTokens({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
sessionId: result.sessionId,
expiresAt: Date.now() + result.expiresIn * 1_000,
user: result.user,
});

navigate('/');
} catch (e) {
form.setError('root', { message: e.message });
}
}

The two-step flow keeps the 2FA input out of the password form until needed.

Sign-in flow with WebAuthn / passkey

const challenge = await authService.getWebAuthnChallenge({ email });
const credential = await navigator.credentials.get({ publicKey: challenge });
const result = await authService.verifyWebAuthn({ credential });
await auth.setTokens(result);

The manager doesn't care about the source — it stores tokens the same way regardless of method.

Programmatic token access (advanced)

const token = await auth.getAccessToken();
const isAuth = auth.isAuthenticated();
const user = auth.getUser();
const session = auth.getSessionId();

Useful for direct fetch calls outside the RPC client (file uploads, third-party SDKs).

Custom refresh function

new AuthManager({
refreshFn: async (refreshToken) => {
const response = await fetch('/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) throw new Error('refresh failed');
return await response.json(); // { accessToken, refreshToken?, expiresAt? }
},
});

Use when refresh isn't a simple POST — e.g., when you must include a CSRF token, when you sign the refresh request, or when the endpoint lives on a different domain.

Security considerations

  • localStorage tokens are accessible to all scripts on the origin — XSS = token compromise. Mitigations: CSP, no user-controlled HTML, monitor for XSS reports.
  • HttpOnly cookies are immune to XSS but require CSRF protection. Pick one model and stick to it.
  • Don't log tokens. Even at debug level — log kid / code only.
  • Inactivity timeout matters for shared / public computers. Default to 30 min for admin surfaces; longer for personal apps.
  • Token rotation hooks (auth.on('refresh', ...)) can notify the user when sessions rotate — useful for security dashboards.

Best practices

  • One AuthManager per app. Multiple managers means multiple BroadcastChannels and possible state divergence.
  • Wire the manager once at boot, before any RPC calls fire.
  • Use expiresAt for proactive refresh. Reactive-only refresh produces one failed request per token cycle.
  • Always await auth.setTokens(...) before navigating — the first post-sign-in render needs valid auth.
  • crossTabSync: true unless you have a specific reason not to.

Anti-patterns

  • Storing tokens in both cookies and localStorage. Pick one. Mixed approaches cause refresh / clear bugs.
  • Setting inactivityTimeout on a "watch-only" dashboard embedded in a kiosk. The user is the screen, not someone typing.
  • Custom inactivity timer alongside AuthManager's. Conflicting timers; one wins, one doesn't.
  • Skipping await on auth.refresh(). The refresh fires but the next call still uses the old token.

See also