@dloizides/auth-client 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +113 -0
- package/README.md +138 -42
- package/dist/AuthClient-Dim7HPRz.d.mts +433 -0
- package/dist/AuthClient-Dim7HPRz.d.ts +433 -0
- package/dist/index.d.mts +204 -109
- package/dist/index.d.ts +204 -109
- package/dist/index.js +587 -34
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +580 -35
- package/dist/index.mjs.map +1 -1
- package/dist/react.d.mts +62 -0
- package/dist/react.d.ts +62 -0
- package/dist/react.js +65 -0
- package/dist/react.js.map +1 -0
- package/dist/react.mjs +58 -0
- package/dist/react.mjs.map +1 -0
- package/package.json +43 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,118 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.0 (2026-05-07)
|
|
4
|
+
|
|
5
|
+
Major release. Extends the realm-aware OIDC core into a single source of truth for every auth surface in the dloizides.com portfolio: web cookies, mobile secure storage, biometric gating, silent token refresh with single-flight, inactivity enforcement, password reset, and sessions management.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
#### New entry points
|
|
10
|
+
|
|
11
|
+
- `@dloizides/auth-client/react` — React Query hooks. Imported separately so non-React consumers (or pure-utility usage) don't load `react` / `@tanstack/react-query`.
|
|
12
|
+
|
|
13
|
+
#### Storage adapters
|
|
14
|
+
|
|
15
|
+
- `CookieTokenStorage` — web adapter that pairs an in-memory access token with a backend-managed `__Host-refresh` httpOnly cookie. The adapter intentionally does NOT persist refresh material into JS-readable storage; refresh swaps go via `/auth/refresh-cookie` with `credentials: 'include'`.
|
|
16
|
+
- `SecureStoreTokenStorage` — mobile adapter for `expo-secure-store`. Splits tokens across four slots (`auth.access`, `auth.refresh`, `auth.id`, `auth.expiresAt`) so the OS can apply different ACLs per slot. Optional `requireAuthentication` triggers OS biometric prompts on read. Optional `biometricGate` adds an in-process biometric check before reads.
|
|
17
|
+
|
|
18
|
+
#### Biometric
|
|
19
|
+
|
|
20
|
+
- `BiometricGate` — wraps `expo-local-authentication`. Provides `isAvailable`, `prompt`, `setEnabled`, `unlock`, `hydrate`. Opt-in by default. After 3 consecutive `unlock()` failures, throws `locked out` so consumers force a re-login.
|
|
21
|
+
|
|
22
|
+
#### Refresh / inactivity
|
|
23
|
+
|
|
24
|
+
- `RefreshInterceptor` — single-flight refresh queue with pluggable `RefreshFn`. Concurrent 401s join the same in-flight refresh; on failure the storage clears and `sessionExpired` fires once. Decoupled from transport — works for both `/auth/refresh` (mobile) and `/auth/refresh-cookie` (web).
|
|
25
|
+
- `InactivityTracker` — persists `lastRefreshedAt` via a pluggable `InactivityStore` and decides whether the session has aged past `maxInactivityDays` (default 90).
|
|
26
|
+
|
|
27
|
+
#### Events
|
|
28
|
+
|
|
29
|
+
- `AuthEventEmitter` — tiny zero-dependency event emitter with `sessionExpired` event. Subscriptions return an unsubscribe function. Snapshot dispatch so listeners may unsubscribe mid-emit.
|
|
30
|
+
|
|
31
|
+
#### HTTP transport
|
|
32
|
+
|
|
33
|
+
- `HttpClient` interface + `createFetchHttpClient(fetch)` factory. Lets the auth API client work with native fetch, axios, ky, or any caller-supplied transport.
|
|
34
|
+
|
|
35
|
+
#### API client
|
|
36
|
+
|
|
37
|
+
- `AuthApiClient` — typed wrapper for `IdentityService` auth endpoints: `loginWithOtp`, `loginWithPassword`, `refreshCookie`, `logout`, `forgotPassword`, `resetPassword`, `listSessions`, `revokeSession`. Supports optional Bearer auth and cookie credentials.
|
|
38
|
+
|
|
39
|
+
#### `AuthClient` collaborators
|
|
40
|
+
|
|
41
|
+
- `AuthClient` constructor accepts an optional `AuthClientCollaborators` bag: `{ api, interceptor, inactivityTracker, events }`. v1 callers continue to work — collaborators are all optional.
|
|
42
|
+
- New methods on `AuthClient`: `init()`, `refresh()`, `loginWithOtp()`, `loginWithPassword()`, `logout({ everywhere })`, `requestPasswordReset()`, `confirmPasswordReset()`, `on('sessionExpired', listener)`.
|
|
43
|
+
- `buildAuthorizationUrl({ offlineAccess: true })` appends `offline_access` to scope (idempotent if already present).
|
|
44
|
+
|
|
45
|
+
#### React Query hooks (under `@dloizides/auth-client/react`)
|
|
46
|
+
|
|
47
|
+
- `useForgotPassword({ api })` — POST `/auth/forgot-password`.
|
|
48
|
+
- `useResetPassword({ api })` — POST `/auth/reset-password`.
|
|
49
|
+
- `useSessions({ api })` — GET `/me/sessions` with exported `SESSIONS_QUERY_KEY` for invalidation.
|
|
50
|
+
- `useRevokeSession({ api })` — POST `/me/sessions/{id}/revoke`. Auto-invalidates the sessions query.
|
|
51
|
+
- `useLogoutEverywhere({ client })` — calls `AuthClient.logout({ everywhere: true })` and invalidates the sessions query.
|
|
52
|
+
|
|
53
|
+
### Peer dependencies
|
|
54
|
+
|
|
55
|
+
- `react` (`>=17.0.0`) — optional, only needed when importing from `@dloizides/auth-client/react`.
|
|
56
|
+
- `@tanstack/react-query` (`^5.0.0`) — optional, same as react.
|
|
57
|
+
- `expo-secure-store` — optional, only needed by `SecureStoreTokenStorage` consumers (i.e. mobile). Web bundles never load it.
|
|
58
|
+
- `expo-local-authentication` — optional, only needed by `BiometricGate` consumers.
|
|
59
|
+
|
|
60
|
+
### Migration from v1.x
|
|
61
|
+
|
|
62
|
+
The v1 API is fully preserved. To upgrade in a no-op way:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// v1 — still works in v2
|
|
66
|
+
const auth = new AuthClient(config, storage);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
To opt into v2 features, pass collaborators:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import {
|
|
73
|
+
AuthClient,
|
|
74
|
+
AuthApiClient,
|
|
75
|
+
RefreshInterceptor,
|
|
76
|
+
InactivityTracker,
|
|
77
|
+
AuthEventEmitter,
|
|
78
|
+
CookieTokenStorage, // web
|
|
79
|
+
createFetchHttpClient,
|
|
80
|
+
} from '@dloizides/auth-client';
|
|
81
|
+
|
|
82
|
+
const events = new AuthEventEmitter();
|
|
83
|
+
const storage = new CookieTokenStorage();
|
|
84
|
+
const http = createFetchHttpClient(fetch);
|
|
85
|
+
const api = new AuthApiClient({ http, baseUrl: 'https://api.dloizides.com', useCredentials: true });
|
|
86
|
+
const interceptor = new RefreshInterceptor({
|
|
87
|
+
storage,
|
|
88
|
+
events,
|
|
89
|
+
refresh: async () => {
|
|
90
|
+
const raw = await api.refreshCookie();
|
|
91
|
+
if (typeof raw.access_token !== 'string') return null;
|
|
92
|
+
// …convert to AuthTokens
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
const inactivityTracker = new InactivityTracker({
|
|
96
|
+
store: yourPlatformInactivityStore,
|
|
97
|
+
maxInactivityDays: 90,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const auth = new AuthClient(config, storage, { api, interceptor, inactivityTracker, events });
|
|
101
|
+
|
|
102
|
+
events.on('sessionExpired', () => navigate('/login'));
|
|
103
|
+
const { hasSession } = await auth.init();
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Hooks live in the React entry point:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { useSessions, useRevokeSession } from '@dloizides/auth-client/react';
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Coverage
|
|
113
|
+
|
|
114
|
+
100% statements / branches / functions / lines (290 tests).
|
|
115
|
+
|
|
3
116
|
## 1.0.0 (2026-05-01)
|
|
4
117
|
|
|
5
118
|
Initial production release. Extracts realm-aware auth helpers from `BaseClient/src/auth/`.
|
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# @dloizides/auth-client
|
|
2
2
|
|
|
3
|
-
Realm-aware Keycloak / OIDC
|
|
3
|
+
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.
|
|
4
4
|
|
|
5
|
-
## Why
|
|
5
|
+
## Why one package across four products
|
|
6
6
|
|
|
7
|
-
Phase 2 of the Questioner ⇄ OnlineMenu
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
8
10
|
|
|
9
11
|
## Install
|
|
10
12
|
|
|
@@ -12,81 +14,175 @@ Phase 2 of the Questioner ⇄ OnlineMenu product split puts each product on its
|
|
|
12
14
|
npm install @dloizides/auth-client
|
|
13
15
|
```
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
Optional peer dependencies:
|
|
18
|
+
|
|
19
|
+
| Peer | Required when |
|
|
20
|
+
|------|---------------|
|
|
21
|
+
| `react` (`>=17`) | Importing from `@dloizides/auth-client/react` |
|
|
22
|
+
| `@tanstack/react-query` (`^5`) | Importing from `@dloizides/auth-client/react` |
|
|
23
|
+
| `expo-secure-store` | Using `SecureStoreTokenStorage` (mobile only) |
|
|
24
|
+
| `expo-local-authentication` | Using `BiometricGate` (mobile only) |
|
|
25
|
+
|
|
26
|
+
Web bundles never pull in `expo-*` packages — those modules import via injected adapter interfaces, not direct module references.
|
|
27
|
+
|
|
28
|
+
## Quick start (web, cookie auth)
|
|
16
29
|
|
|
17
30
|
```ts
|
|
18
31
|
import {
|
|
19
32
|
AuthClient,
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
AuthApiClient,
|
|
34
|
+
RefreshInterceptor,
|
|
35
|
+
InactivityTracker,
|
|
36
|
+
AuthEventEmitter,
|
|
37
|
+
CookieTokenStorage,
|
|
38
|
+
createFetchHttpClient,
|
|
39
|
+
tokenResponseToAuthTokens,
|
|
40
|
+
normalizeTokenResponse,
|
|
22
41
|
} from '@dloizides/auth-client';
|
|
23
42
|
|
|
24
|
-
const
|
|
43
|
+
const events = new AuthEventEmitter();
|
|
44
|
+
const storage = new CookieTokenStorage();
|
|
45
|
+
const http = createFetchHttpClient(window.fetch.bind(window));
|
|
46
|
+
const api = new AuthApiClient({
|
|
47
|
+
http,
|
|
48
|
+
baseUrl: 'https://api.dloizides.com',
|
|
49
|
+
useCredentials: true, // sends the __Host-refresh cookie
|
|
50
|
+
getAccessToken: () => storage.read().then((t) => t?.accessToken ?? null),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const interceptor = new RefreshInterceptor({
|
|
54
|
+
storage,
|
|
55
|
+
events,
|
|
56
|
+
refresh: async () => {
|
|
57
|
+
const raw = await api.refreshCookie();
|
|
58
|
+
if (typeof raw.access_token !== 'string' || raw.access_token === '') return null;
|
|
59
|
+
return tokenResponseToAuthTokens(normalizeTokenResponse({ ...raw, access_token: raw.access_token }));
|
|
60
|
+
},
|
|
61
|
+
onRefreshSuccess: () => inactivity.markActive(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const inactivity = new InactivityTracker({ store: yourInactivityStore });
|
|
25
65
|
|
|
26
66
|
const auth = new AuthClient(
|
|
27
67
|
{
|
|
28
68
|
baseUrl: 'https://identity.dloizides.com',
|
|
29
|
-
realm: 'OnlineMenu',
|
|
69
|
+
realm: 'OnlineMenu',
|
|
30
70
|
clientId: 'online-menu-client',
|
|
31
71
|
redirectUri: 'http://localhost:8082',
|
|
32
72
|
scope: 'openid profile email offline_access',
|
|
33
73
|
},
|
|
34
74
|
storage,
|
|
75
|
+
{ api, interceptor, inactivityTracker: inactivity, events },
|
|
35
76
|
);
|
|
36
77
|
|
|
37
|
-
|
|
38
|
-
auth.
|
|
39
|
-
auth.tokenEndpoint; // .../realms/OnlineMenu/protocol/openid-connect/token
|
|
40
|
-
auth.userInfoEndpoint; // .../realms/OnlineMenu/protocol/openid-connect/userinfo
|
|
41
|
-
auth.buildAuthorizationUrl({ state: 'xyz', codeChallenge: 'abc' });
|
|
78
|
+
events.on('sessionExpired', () => navigate('/login'));
|
|
79
|
+
const { hasSession } = await auth.init();
|
|
42
80
|
```
|
|
43
81
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
If your app currently stores only an issuer URL like `https://identity.dloizides.com/realms/OnlineMenu`, derive the realm and base URL automatically:
|
|
82
|
+
## Quick start (mobile, secure-store)
|
|
47
83
|
|
|
48
84
|
```ts
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
import * as SecureStore from 'expo-secure-store';
|
|
86
|
+
import * as LocalAuthentication from 'expo-local-authentication';
|
|
87
|
+
import {
|
|
88
|
+
AuthClient,
|
|
89
|
+
AuthApiClient,
|
|
90
|
+
BiometricGate,
|
|
91
|
+
InactivityTracker,
|
|
92
|
+
RefreshInterceptor,
|
|
93
|
+
SecureStoreTokenStorage,
|
|
94
|
+
AuthEventEmitter,
|
|
95
|
+
createFetchHttpClient,
|
|
96
|
+
} from '@dloizides/auth-client';
|
|
97
|
+
|
|
98
|
+
const events = new AuthEventEmitter();
|
|
99
|
+
|
|
100
|
+
const biometricGate = new BiometricGate({
|
|
101
|
+
localAuth: {
|
|
102
|
+
hasHardwareAsync: LocalAuthentication.hasHardwareAsync,
|
|
103
|
+
isEnrolledAsync: LocalAuthentication.isEnrolledAsync,
|
|
104
|
+
authenticateAsync: (opts) => LocalAuthentication.authenticateAsync(opts),
|
|
53
105
|
},
|
|
54
|
-
|
|
55
|
-
);
|
|
106
|
+
flagStore: yourBiometricFlagStore, // optional persistence for the user's opt-in
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const storage = new SecureStoreTokenStorage({
|
|
110
|
+
secureStore: {
|
|
111
|
+
getItemAsync: SecureStore.getItemAsync,
|
|
112
|
+
setItemAsync: SecureStore.setItemAsync,
|
|
113
|
+
deleteItemAsync: SecureStore.deleteItemAsync,
|
|
114
|
+
},
|
|
115
|
+
requireAuthentication: true,
|
|
116
|
+
biometricGate,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await biometricGate.hydrate();
|
|
56
120
|
```
|
|
57
121
|
|
|
58
|
-
##
|
|
122
|
+
## React Query hooks
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { useSessions, useRevokeSession, useLogoutEverywhere, useForgotPassword, useResetPassword } from '@dloizides/auth-client/react';
|
|
126
|
+
|
|
127
|
+
const { data: sessions, isLoading } = useSessions({ api });
|
|
128
|
+
const revoke = useRevokeSession({ api });
|
|
129
|
+
const logoutEverywhere = useLogoutEverywhere({ client: auth });
|
|
130
|
+
const forgot = useForgotPassword({ api });
|
|
131
|
+
const reset = useResetPassword({ api });
|
|
132
|
+
|
|
133
|
+
// In your component
|
|
134
|
+
revoke.mutate(sessionId);
|
|
135
|
+
logoutEverywhere.mutate();
|
|
136
|
+
forgot.mutate({ email });
|
|
137
|
+
reset.mutate({ token, newPassword });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Lifecycle events
|
|
59
141
|
|
|
60
|
-
|
|
142
|
+
`AuthEventEmitter` exposes a `sessionExpired` event:
|
|
61
143
|
|
|
62
|
-
|
|
144
|
+
```ts
|
|
145
|
+
auth.on('sessionExpired', () => {
|
|
146
|
+
navigate('/login');
|
|
147
|
+
});
|
|
148
|
+
```
|
|
63
149
|
|
|
64
|
-
|
|
150
|
+
`sessionExpired` fires when:
|
|
151
|
+
|
|
152
|
+
- The inactivity tracker reports the session has aged past `maxInactivityDays` (during `auth.init()`).
|
|
153
|
+
- A refresh attempt fails (`RefreshInterceptor` clears storage and emits the event exactly once per attempt, even when joined by N concurrent waiters).
|
|
154
|
+
|
|
155
|
+
## What's in the box
|
|
65
156
|
|
|
66
|
-
-
|
|
67
|
-
- `BrowserStorageTokenStorage` — wraps any `Storage`-shaped backend (`localStorage`, `sessionStorage`, AsyncStorage shim).
|
|
157
|
+
### Core (`@dloizides/auth-client`)
|
|
68
158
|
|
|
69
|
-
|
|
159
|
+
- `AuthClient` — realm-aware orchestrator. `init()`, `refresh()`, `loginWithOtp()`, `loginWithPassword()`, `logout({ everywhere })`, `requestPasswordReset()`, `confirmPasswordReset()`, plus the v1 surface (`getAccessToken`, `getTokens`, `setTokens`, `clearTokens`, `buildAuthorizationUrl`, etc.).
|
|
160
|
+
- `AuthApiClient` — typed wrapper for IdentityService auth endpoints.
|
|
161
|
+
- `AuthEventEmitter` — `sessionExpired` event.
|
|
162
|
+
- `RefreshInterceptor` — single-flight refresh queue.
|
|
163
|
+
- `InactivityTracker` — 90-day default timeout (configurable).
|
|
164
|
+
- Storage adapters: `InMemoryTokenStorage`, `BrowserStorageTokenStorage`, `CookieTokenStorage`, `SecureStoreTokenStorage`.
|
|
165
|
+
- `BiometricGate` — wraps `expo-local-authentication`. 3-strikes lockout default.
|
|
166
|
+
- `createFetchHttpClient(fetch)` — `HttpClient` factory.
|
|
167
|
+
- All v1 pure helpers (URL builders, token body builders, JWT decoder, user normaliser).
|
|
70
168
|
|
|
71
|
-
###
|
|
169
|
+
### React (`@dloizides/auth-client/react`)
|
|
72
170
|
|
|
73
|
-
- `
|
|
74
|
-
- `
|
|
75
|
-
- `
|
|
76
|
-
- `extractAuthCode(response)` — pull the `code` out of an `expo-auth-session` (or browser) redirect response.
|
|
77
|
-
- `normalizeTokenResponse(raw)` / `tokenResponseToAuthTokens(response)` — snake_case → camelCase + absolute `expiresAt` computation.
|
|
78
|
-
- `isTokenExpired(tokens, leewayMs?, now?)` / `computeExpiresAt(expiresIn, now?)` — clock-aware expiry checks with default 30 s leeway.
|
|
79
|
-
- `decodeJwt<T>(token)` — base64url-decode the payload of a compact JWT (no signature verification — UI only).
|
|
80
|
-
- `normalizeKeycloakUser(userInfo)` — collapse Keycloak `realm_access` + `resource_access` roles into a deduplicated `roles[]` array, pick a sensible `displayName` / `username`.
|
|
171
|
+
- `useForgotPassword`, `useResetPassword` — mutation hooks.
|
|
172
|
+
- `useSessions` — query hook with exported `SESSIONS_QUERY_KEY`.
|
|
173
|
+
- `useRevokeSession`, `useLogoutEverywhere` — mutation hooks that auto-invalidate the sessions query.
|
|
81
174
|
|
|
82
|
-
|
|
175
|
+
## Architecture decisions baked in
|
|
83
176
|
|
|
84
|
-
-
|
|
85
|
-
|
|
177
|
+
1. **Biometric is opt-in** via `BiometricGate.setEnabled(true)`. Default off so a fresh install doesn't gate the user behind a hardware prompt.
|
|
178
|
+
2. **Inactivity timeout default 90 days** (configurable). Mobile tasks chose this number; web matches.
|
|
179
|
+
3. **Single account per device.** The package has no multi-account surface — one refresh-token slot, period.
|
|
180
|
+
4. **No `react-native` import 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.
|
|
181
|
+
5. **Cookie refresh material is server-managed.** `CookieTokenStorage.write()` discards `refreshToken` from the JS heap on purpose — refresh swaps go via `/auth/refresh-cookie` with `credentials: 'include'`.
|
|
86
182
|
|
|
87
183
|
## Coverage
|
|
88
184
|
|
|
89
|
-
100% statements / branches / functions / lines. Test runner: Jest with `ts-jest`.
|
|
185
|
+
100% statements / branches / functions / lines (290 tests). Test runner: Jest with `ts-jest`.
|
|
90
186
|
|
|
91
187
|
## License
|
|
92
188
|
|