@dloizides/auth-client 1.0.0 → 2.1.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 CHANGED
@@ -1,5 +1,181 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0 (2026-05-17)
4
+
5
+ Additive release. Lays the groundwork for the "shrink identity service"
6
+ migration by extracting the OIDC primitives that three apps (BaseClient,
7
+ `apps/erevna-web`, `apps/katalogos-web`) had each duplicated in their own
8
+ `useKeycloakExchange.ts` files. No breaking changes — v2.0 callers continue
9
+ to work unchanged.
10
+
11
+ ### Added
12
+
13
+ #### New `/oidc` sub-path entry
14
+
15
+ - `@dloizides/auth-client/oidc` — Pure OIDC primitives (no React, no hooks).
16
+ Lets non-React consumers tree-shake the `AuthClient` class away entirely.
17
+
18
+ #### OIDC primitives (also re-exported from the main entry)
19
+
20
+ - `fetchDiscoveryDocument({ issuerUrl, http })` — Fetches
21
+ `{issuer}/.well-known/openid-configuration` and caches the result per
22
+ issuer URL for the lifetime of the process. Throws on non-2xx or invalid
23
+ metadata. Cache cleared with `clearDiscoveryCache()` (test-only).
24
+ - `generateCodeVerifier(length?)` — RFC 7636-compliant PKCE verifier
25
+ generator. Defaults to 64 chars; enforces the 43..128 band.
26
+ - `deriveCodeChallenge(verifier)` — `BASE64URL(SHA256(verifier))` via
27
+ `crypto.subtle` (browser + Node 16+). Matches the RFC 7636 Appendix B
28
+ test vector.
29
+ - `generatePkcePair(length?)` — Convenience: fresh verifier + matching S256
30
+ challenge in one call.
31
+ - `exchangeAuthorizationCode({ http, baseUrl, realm, clientId, code,
32
+ redirectUri, codeVerifier })` — POSTs `grant_type=authorization_code` to
33
+ the realm's token endpoint. Returns a normalised `TokenResponse`.
34
+ - `refreshAccessToken({ http, baseUrl, realm, clientId, refreshToken })` —
35
+ POSTs `grant_type=refresh_token`. Same return shape.
36
+
37
+ #### `AuthClient` v2.1 surface
38
+
39
+ - `useDirectKcAuth?: boolean` config flag. Default `false`. When `true`,
40
+ apps can route their PKCE flow through the shared `exchangeAuthorizationCode`
41
+ primitive instead of the proxied identity-api `/auth/login` flow. The
42
+ flag is read-only at runtime via `AuthClient.isDirectMode()` so apps can
43
+ render conditionally on whether they've opted in.
44
+ - `acceptDirectKcTokens(response)` — Persists a `TokenResponse` produced by
45
+ the direct-KC flow into the configured storage, marks the inactivity
46
+ tracker active, and fires `onTokenAcquired`. Use this from the app's
47
+ `useKeycloakExchange.ts` hook after `exchangeAuthorizationCode()` resolves.
48
+ - `acceptDirectKcRefresh(response)` — Same as above but fires
49
+ `onTokenRefreshed` instead. Use after `refreshAccessToken()` swaps.
50
+ - `onTokenAcquired?: (tokens) => void` collaborator — fires after any login
51
+ path (OTP, password, direct-KC) successfully persists a fresh token
52
+ bundle. For app-side analytics/logging only — NOT designed for BFF
53
+ integration (Phase 2 designs that fresh).
54
+ - `onTokenRefreshed?: (tokens) => void` collaborator — fires after any
55
+ refresh (interceptor or direct-KC) persists a fresh bundle.
56
+
57
+ ### Notes
58
+
59
+ - The `useDirectKcAuth` flag is the dormant-path flip mechanism. After v2.1
60
+ ships, apps still default to the proxied path. Per-app cutover (flipping
61
+ the flag to `true`) happens in subsequent steps of the
62
+ shrink-identity-to-tenant-service migration.
63
+ - The proxied `/auth/login`, `/auth/refresh`, `/auth/refresh-cookie` methods
64
+ on `AuthApiClient` are unchanged — production apps still call them.
65
+
66
+ ## 2.0.0 (2026-05-07)
67
+
68
+ 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.
69
+
70
+ ### Added
71
+
72
+ #### New entry points
73
+
74
+ - `@dloizides/auth-client/react` — React Query hooks. Imported separately so non-React consumers (or pure-utility usage) don't load `react` / `@tanstack/react-query`.
75
+
76
+ #### Storage adapters
77
+
78
+ - `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'`.
79
+ - `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.
80
+
81
+ #### Biometric
82
+
83
+ - `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.
84
+
85
+ #### Refresh / inactivity
86
+
87
+ - `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).
88
+ - `InactivityTracker` — persists `lastRefreshedAt` via a pluggable `InactivityStore` and decides whether the session has aged past `maxInactivityDays` (default 90).
89
+
90
+ #### Events
91
+
92
+ - `AuthEventEmitter` — tiny zero-dependency event emitter with `sessionExpired` event. Subscriptions return an unsubscribe function. Snapshot dispatch so listeners may unsubscribe mid-emit.
93
+
94
+ #### HTTP transport
95
+
96
+ - `HttpClient` interface + `createFetchHttpClient(fetch)` factory. Lets the auth API client work with native fetch, axios, ky, or any caller-supplied transport.
97
+
98
+ #### API client
99
+
100
+ - `AuthApiClient` — typed wrapper for `IdentityService` auth endpoints: `loginWithOtp`, `loginWithPassword`, `refreshCookie`, `logout`, `forgotPassword`, `resetPassword`, `listSessions`, `revokeSession`. Supports optional Bearer auth and cookie credentials.
101
+
102
+ #### `AuthClient` collaborators
103
+
104
+ - `AuthClient` constructor accepts an optional `AuthClientCollaborators` bag: `{ api, interceptor, inactivityTracker, events }`. v1 callers continue to work — collaborators are all optional.
105
+ - New methods on `AuthClient`: `init()`, `refresh()`, `loginWithOtp()`, `loginWithPassword()`, `logout({ everywhere })`, `requestPasswordReset()`, `confirmPasswordReset()`, `on('sessionExpired', listener)`.
106
+ - `buildAuthorizationUrl({ offlineAccess: true })` appends `offline_access` to scope (idempotent if already present).
107
+
108
+ #### React Query hooks (under `@dloizides/auth-client/react`)
109
+
110
+ - `useForgotPassword({ api })` — POST `/auth/forgot-password`.
111
+ - `useResetPassword({ api })` — POST `/auth/reset-password`.
112
+ - `useSessions({ api })` — GET `/me/sessions` with exported `SESSIONS_QUERY_KEY` for invalidation.
113
+ - `useRevokeSession({ api })` — POST `/me/sessions/{id}/revoke`. Auto-invalidates the sessions query.
114
+ - `useLogoutEverywhere({ client })` — calls `AuthClient.logout({ everywhere: true })` and invalidates the sessions query.
115
+
116
+ ### Peer dependencies
117
+
118
+ - `react` (`>=17.0.0`) — optional, only needed when importing from `@dloizides/auth-client/react`.
119
+ - `@tanstack/react-query` (`^5.0.0`) — optional, same as react.
120
+ - `expo-secure-store` — optional, only needed by `SecureStoreTokenStorage` consumers (i.e. mobile). Web bundles never load it.
121
+ - `expo-local-authentication` — optional, only needed by `BiometricGate` consumers.
122
+
123
+ ### Migration from v1.x
124
+
125
+ The v1 API is fully preserved. To upgrade in a no-op way:
126
+
127
+ ```ts
128
+ // v1 — still works in v2
129
+ const auth = new AuthClient(config, storage);
130
+ ```
131
+
132
+ To opt into v2 features, pass collaborators:
133
+
134
+ ```ts
135
+ import {
136
+ AuthClient,
137
+ AuthApiClient,
138
+ RefreshInterceptor,
139
+ InactivityTracker,
140
+ AuthEventEmitter,
141
+ CookieTokenStorage, // web
142
+ createFetchHttpClient,
143
+ } from '@dloizides/auth-client';
144
+
145
+ const events = new AuthEventEmitter();
146
+ const storage = new CookieTokenStorage();
147
+ const http = createFetchHttpClient(fetch);
148
+ const api = new AuthApiClient({ http, baseUrl: 'https://api.dloizides.com', useCredentials: true });
149
+ const interceptor = new RefreshInterceptor({
150
+ storage,
151
+ events,
152
+ refresh: async () => {
153
+ const raw = await api.refreshCookie();
154
+ if (typeof raw.access_token !== 'string') return null;
155
+ // …convert to AuthTokens
156
+ },
157
+ });
158
+ const inactivityTracker = new InactivityTracker({
159
+ store: yourPlatformInactivityStore,
160
+ maxInactivityDays: 90,
161
+ });
162
+
163
+ const auth = new AuthClient(config, storage, { api, interceptor, inactivityTracker, events });
164
+
165
+ events.on('sessionExpired', () => navigate('/login'));
166
+ const { hasSession } = await auth.init();
167
+ ```
168
+
169
+ Hooks live in the React entry point:
170
+
171
+ ```ts
172
+ import { useSessions, useRevokeSession } from '@dloizides/auth-client/react';
173
+ ```
174
+
175
+ ### Coverage
176
+
177
+ 100% statements / branches / functions / lines (290 tests).
178
+
3
179
  ## 1.0.0 (2026-05-01)
4
180
 
5
181
  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 helpers for the dloizides.com portfolio. Takes `realm` and `clientId` as config never hardcodes them so the same package serves every product (Questioner, OnlineMenu, future apps) with its own Keycloak realm.
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 product split puts each product on its own Keycloak realm. A Questioner-realm token must never be accepted by the OnlineMenu service, and vice versa. This package centralises every realm-aware concern (URL derivation, PKCE flow building blocks, token persistence, JWT decoding, user normalisation) so each app instance is wired to exactly one realm via constructor config.
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
- ## Quick start
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
- BrowserStorageTokenStorage,
21
- normalizeKeycloakUser,
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 storage = new BrowserStorageTokenStorage({ storage: localStorage });
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', // product-specific
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
- // Derived URLs (always realm-aware):
38
- auth.issuerUrl; // https://identity.dloizides.com/realms/OnlineMenu
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
- ### Migrating from a legacy issuer URL
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
- const auth = AuthClient.fromIssuerUrl(
50
- {
51
- issuerUrl: process.env.KEYCLOAK_ISSUER!,
52
- clientId: 'online-menu-client',
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
- storage,
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
- ## What's in the box
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
- ### Class
142
+ `AuthEventEmitter` exposes a `sessionExpired` event:
61
143
 
62
- - `AuthClient` — realm-aware container for config + storage. Exposes `issuerUrl`, `authorizationEndpoint`, `tokenEndpoint`, `userInfoEndpoint`, `logoutEndpoint`, `buildAuthorizationUrl()`, `getAccessToken()`, `getTokens()` / `setTokens()` / `clearTokens()`.
144
+ ```ts
145
+ auth.on('sessionExpired', () => {
146
+ navigate('/login');
147
+ });
148
+ ```
63
149
 
64
- ### Token storage adapters
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
- - `InMemoryTokenStorage` — for tests and SSR.
67
- - `BrowserStorageTokenStorage` — wraps any `Storage`-shaped backend (`localStorage`, `sessionStorage`, AsyncStorage shim).
157
+ ### Core (`@dloizides/auth-client`)
68
158
 
69
- Bring your own implementation by satisfying the `TokenStorage` interface (`read` / `write` / `clear`).
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
- ### Pure helpers (zero-dependency, fully tested)
169
+ ### React (`@dloizides/auth-client/react`)
72
170
 
73
- - `parseRealmFromIssuer(url)` / `parseBaseUrlFromIssuer(url)` — split a Keycloak issuer URL.
74
- - `buildIssuerUrl`, `buildAuthorizationEndpoint`, `buildTokenEndpoint`, `buildUserInfoEndpoint`, `buildLogoutEndpoint`, `buildAuthorizationUrl` realm-aware URL builders.
75
- - `buildAuthorizationCodeBody`, `buildRefreshTokenBody` — `application/x-www-form-urlencoded` body helpers for the token endpoint.
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
- ### Types
175
+ ## Architecture decisions baked in
83
176
 
84
- - `AuthClientConfig`, `AuthTokens`, `TokenStorage`, `RawTokenResponse`, `TokenResponse`
85
- - `KeycloakUserInfo`, `NormalizedUser`, `KeycloakRoles` (`const enum` + `isKeycloakRole` guard)
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