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.
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.
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.
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();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();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 });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).
AuthClient— realm-aware orchestrator.init(),refresh(),loginWithOtp(),loginWithPassword(),logout({ everywhere }),requestPasswordReset(),confirmPasswordReset(), plus the v1 surface (getAccessToken,getTokens,setTokens,clearTokens,buildAuthorizationUrl, etc.).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).
useForgotPassword,useResetPassword— mutation hooks.useSessions— query hook with exportedSESSIONS_QUERY_KEY.useRevokeSession,useLogoutEverywhere— mutation hooks that auto-invalidate the sessions query.
- 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'.
100% statements / branches / functions / lines (290 tests). Test runner: Jest with ts-jest.
MIT