@dloizides/auth-client
v4.0.0
Published
Auth client for the dloizides.com portfolio. v3 adds BffAuthClient — the same-origin client for a per-app Backend-For-Frontend. Also: realm-aware Keycloak/OIDC (PKCE/ROPC), token refresh, storage adapters, hooks for sessions and password reset.
Maintainers
Readme
@dloizides/auth-client
Realm-aware Keycloak / OIDC client for the dloizides.com portfolio. v2 extends the v1 PKCE / token-storage core with platform-specific adapters (cookie web, secure-store mobile, biometric gate), silent token refresh with single-flight, inactivity enforcement, password reset, and React Query hooks for sessions management.
Why one package across four products
Phase 2 of the Questioner ⇄ OnlineMenu split puts each product on its own Keycloak realm. A Questioner-realm token must never be accepted by the OnlineMenu service, and vice versa. The v1 surface centralised every realm-aware concern (URL derivation, PKCE building blocks, token persistence, JWT decoding, user normalisation) so each app instance is wired to exactly one realm via constructor config.
v2 widens that to all auth machinery: persistent sessions, refresh, biometric gating, sessions list/revoke, password reset, login orchestration. Same library, configured per-platform via adapters. Adding a fifth product or a new mobile app means picking the right adapter and going.
Install
npm install @dloizides/auth-clientOptional peer dependencies:
| Peer | Required when |
|------|---------------|
| react (>=17) | Importing from @dloizides/auth-client/react |
| @tanstack/react-query (^5) | Importing from @dloizides/auth-client/react |
| expo-secure-store | Using SecureStoreTokenStorage (mobile only) |
| expo-local-authentication | Using BiometricGate (mobile only) |
Web bundles never pull in expo-* packages — those modules import via injected adapter interfaces, not direct module references.
Quick start (web, cookie auth)
import {
AuthClient,
AuthApiClient,
RefreshInterceptor,
InactivityTracker,
AuthEventEmitter,
CookieTokenStorage,
createFetchHttpClient,
tokenResponseToAuthTokens,
normalizeTokenResponse,
} from '@dloizides/auth-client';
const events = new AuthEventEmitter();
const storage = new CookieTokenStorage();
const http = createFetchHttpClient(window.fetch.bind(window));
const api = new AuthApiClient({
http,
baseUrl: 'https://api.dloizides.com',
useCredentials: true, // sends the __Host-refresh cookie
getAccessToken: () => storage.read().then((t) => t?.accessToken ?? null),
});
const interceptor = new RefreshInterceptor({
storage,
events,
refresh: async () => {
const raw = await api.refreshCookie();
if (typeof raw.access_token !== 'string' || raw.access_token === '') return null;
return tokenResponseToAuthTokens(normalizeTokenResponse({ ...raw, access_token: raw.access_token }));
},
onRefreshSuccess: () => inactivity.markActive(),
});
const inactivity = new InactivityTracker({ store: yourInactivityStore });
const auth = new AuthClient(
{
baseUrl: 'https://identity.dloizides.com',
realm: 'OnlineMenu',
clientId: 'online-menu-client',
redirectUri: 'http://localhost:8082',
scope: 'openid profile email offline_access',
},
storage,
{ api, interceptor, inactivityTracker: inactivity, events },
);
events.on('sessionExpired', () => navigate('/login'));
const { hasSession } = await auth.init();Quick start (mobile, secure-store)
import * as SecureStore from 'expo-secure-store';
import * as LocalAuthentication from 'expo-local-authentication';
import {
AuthClient,
AuthApiClient,
BiometricGate,
InactivityTracker,
RefreshInterceptor,
SecureStoreTokenStorage,
AuthEventEmitter,
createFetchHttpClient,
} from '@dloizides/auth-client';
const events = new AuthEventEmitter();
const biometricGate = new BiometricGate({
localAuth: {
hasHardwareAsync: LocalAuthentication.hasHardwareAsync,
isEnrolledAsync: LocalAuthentication.isEnrolledAsync,
authenticateAsync: (opts) => LocalAuthentication.authenticateAsync(opts),
},
flagStore: yourBiometricFlagStore, // optional persistence for the user's opt-in
});
const storage = new SecureStoreTokenStorage({
secureStore: {
getItemAsync: SecureStore.getItemAsync,
setItemAsync: SecureStore.setItemAsync,
deleteItemAsync: SecureStore.deleteItemAsync,
},
requireAuthentication: true,
biometricGate,
});
await biometricGate.hydrate();BFF auth (v3 — recommended)
BffAuthClient is the same-origin client for a per-app Backend-For-Frontend
(bff-katalogos, bff-erevna). The BFF terminates authentication
server-side: it does ROPC against Keycloak with a confidential client, stores
the tokens in a Redis vault, and hands the browser only an opaque httpOnly
session cookie. The SPA never sees a token — an XSS cannot exfiltrate one.
BffAuthClient does no token handling: every call is a same-origin
fetch with credentials: 'include', and state-changing calls carry the
X-BFF-Csrf: 1 header the BFF anti-forgery middleware requires.
import { BffAuthClient, createFetchHttpClient } from '@dloizides/auth-client';
const bff = new BffAuthClient({
http: createFetchHttpClient(window.fetch.bind(window)),
// baseUrl defaults to '' (same-origin) — the production wiring.
});
// Login — the BFF does ROPC server-side and sets the session cookie.
const user = await bff.login({ username, password });
// Bootstrap on app load — null when there is no live session.
const current = await bff.getCurrentUser();
await bff.register({ firstName, lastName, username, email, password, tenantName });
await bff.forgotPassword({ email, resetUrlTemplate });
await bff.resetPassword({ token, newPassword });
await bff.logout();Device-bound PIN unlock (v3.3 — unified-login Increment 3)
A returning, remembered-device, logged-OUT user can re-establish a session with a
4/6/8-digit device PIN. Unlike login / pinLogin (which throw an opaque error on
any non-2xx), the device-PIN methods never throw — they return discriminated
results so the UI can route on status.
// Which methods does this BFF advertise + does this device remember a PIN?
// NEVER throws — safe fallback (['password'], registration off) on any failure.
const config = await bff.getLoginConfig();
if (config.deviceState.hasPin) {
/* render the device-PIN unlock screen */
}
// Bind a PIN to the current strong session.
const enroll = await bff.enrollDevicePin({ pin: '482913', digits: 6 });
// status: 'success' | 'unauthorized' | 'forbidden' | 'invalidPin' | 'error'
// Re-establish a session from a remembered device.
const unlock = await bff.unlockWithDevicePin({ pin: '482913' });
switch (unlock.status) {
case 'success': /* unlock.user — a session cookie was set */ break;
case 'invalid': /* wrong PIN / unknown-or-revoked device */ break;
case 'locked': /* device lockout — unlock.retryAfterSeconds */ break;
case 'rateLimited': /* per-IP limiter — may poll through it */ break;
case 'error': /* network / unexpected */ break;
}
await bff.disableDevicePin(); // true on success, never throwsThe two 429 outcomes are distinct on purpose: the per-IP BffAuth limiter
answers 429 with an empty body (rateLimited — a UI may poll through it),
whereas the device-PIN lockout answers 429 with a JSON { error } body + a
Retry-After header (locked — show a "try again in N s" message).
The direct-KC AuthClient / ROPC surface below is retained for consumers not
yet on a BFF; it is deprecated and removed once every app has migrated.
React Query hooks
import { useSessions, useRevokeSession, useLogoutEverywhere, useForgotPassword, useResetPassword } from '@dloizides/auth-client/react';
const { data: sessions, isLoading } = useSessions({ api });
const revoke = useRevokeSession({ api });
const logoutEverywhere = useLogoutEverywhere({ client: auth });
const forgot = useForgotPassword({ api });
const reset = useResetPassword({ api });
// In your component
revoke.mutate(sessionId);
logoutEverywhere.mutate();
forgot.mutate({ email });
reset.mutate({ token, newPassword });Lifecycle events
AuthEventEmitter exposes a sessionExpired event:
auth.on('sessionExpired', () => {
navigate('/login');
});sessionExpired fires when:
- The inactivity tracker reports the session has aged past
maxInactivityDays(duringauth.init()). - A refresh attempt fails (
RefreshInterceptorclears storage and emits the event exactly once per attempt, even when joined by N concurrent waiters).
What's in the box
Core (@dloizides/auth-client)
BffAuthClient— same-origin client for a per-app Backend-For-Frontend.login(),logout(),getCurrentUser(),register(),forgotPassword(),resetPassword(),requestOtp(),verifyOtp(),pinLogin(), plus the v3.3 device-PIN surface:getLoginConfig(),enrollDevicePin(),unlockWithDevicePin(),disableDevicePin()(discriminated, never-throwing results). No token handling — the BFF owns tokens, the browser owns only an httpOnly cookie. The recommended auth surface (v3).AuthClient— realm-aware orchestrator.init(),refresh(),loginWithOtp(),loginWithPassword(),logout({ everywhere }),requestPasswordReset(),confirmPasswordReset(), plus the v1 surface (getAccessToken,getTokens,setTokens,clearTokens,buildAuthorizationUrl, etc.). Direct-KC ROPC; deprecated in favour ofBffAuthClient.AuthApiClient— typed wrapper for IdentityService auth endpoints.AuthEventEmitter—sessionExpiredevent.RefreshInterceptor— single-flight refresh queue.InactivityTracker— 90-day default timeout (configurable).- Storage adapters:
InMemoryTokenStorage,BrowserStorageTokenStorage,CookieTokenStorage,SecureStoreTokenStorage. BiometricGate— wrapsexpo-local-authentication. 3-strikes lockout default.createFetchHttpClient(fetch)—HttpClientfactory.- All v1 pure helpers (URL builders, token body builders, JWT decoder, user normaliser).
React (@dloizides/auth-client/react)
useForgotPassword,useResetPassword— mutation hooks.useSessions— query hook with exportedSESSIONS_QUERY_KEY.useRevokeSession,useLogoutEverywhere— mutation hooks that auto-invalidate the sessions query.
Architecture decisions baked in
- Biometric is opt-in via
BiometricGate.setEnabled(true). Default off so a fresh install doesn't gate the user behind a hardware prompt. - Inactivity timeout default 90 days (configurable). Mobile tasks chose this number; web matches.
- Single account per device. The package has no multi-account surface — one refresh-token slot, period.
- No
react-nativeimport in package core. RN-specific code lives in adapters that take injected interfaces (SecureStoreLike,LocalAuthLike). Web bundles don't pay for what they don't use. - Cookie refresh material is server-managed.
CookieTokenStorage.write()discardsrefreshTokenfrom the JS heap on purpose — refresh swaps go via/auth/refresh-cookiewithcredentials: 'include'.
Coverage
100% statements / branches / functions / lines (290 tests). Test runner: Jest with ts-jest.
License
MIT
