@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.
@@ -0,0 +1,460 @@
1
+ import { H as HttpClient, T as TokenResponse } from './TokenResponse-CY1CaU2l.mjs';
2
+
3
+ /**
4
+ * Tiny dependency-free event emitter for auth lifecycle events.
5
+ *
6
+ * Consumers subscribe to `onSessionExpired` to navigate to the login screen
7
+ * when refresh fails or the inactivity timeout fires. We don't reach for
8
+ * `EventTarget`/`EventEmitter` because we want one consistent API across web,
9
+ * React Native, and node test environments without polyfills.
10
+ */
11
+ type AuthEventName = 'sessionExpired';
12
+ type AuthEventListener = () => void;
13
+ interface AuthEventUnsubscribe {
14
+ (): void;
15
+ }
16
+ declare class AuthEventEmitter {
17
+ private readonly listeners;
18
+ on(event: AuthEventName, listener: AuthEventListener): AuthEventUnsubscribe;
19
+ emit(event: AuthEventName): void;
20
+ /** Remove all listeners. Useful for `AuthClient.dispose()` and tests. */
21
+ clear(): void;
22
+ }
23
+
24
+ /**
25
+ * Backend session record returned by `GET /me/sessions`.
26
+ *
27
+ * Shape mirrors the existing IdentityService response — see
28
+ * `Services/Identity/.../GetSessions.cs`. Defined as a permissive interface
29
+ * so newer fields (added server-side) flow through without a package bump.
30
+ */
31
+ interface AuthSessionInfo {
32
+ id: string;
33
+ isCurrent?: boolean;
34
+ ipAddress?: string;
35
+ userAgent?: string;
36
+ createdAt?: string;
37
+ lastSeenAt?: string;
38
+ [key: string]: unknown;
39
+ }
40
+ interface AuthApiClientOptions {
41
+ http: HttpClient;
42
+ /** API base, e.g. `https://api.dloizides.com`. No trailing slash needed. */
43
+ baseUrl: string;
44
+ /**
45
+ * Optional supplier of the current access token, used as a Bearer header on
46
+ * authenticated calls (sessions list, revoke, logout). When omitted those
47
+ * calls send no Authorization header — typical for cookie-based web auth.
48
+ */
49
+ getAccessToken?: () => Promise<string | null>;
50
+ /**
51
+ * When true, every request adds `credentials: 'include'`. Required for
52
+ * cookie-based web auth (`__Host-refresh` lives in an httpOnly cookie).
53
+ */
54
+ useCredentials?: boolean;
55
+ }
56
+ interface OtpLoginRequest {
57
+ email: string;
58
+ otp: string;
59
+ tenantId?: string;
60
+ offlineAccess?: boolean;
61
+ }
62
+ interface PasswordLoginRequest {
63
+ email: string;
64
+ password: string;
65
+ tenantId?: string;
66
+ offlineAccess?: boolean;
67
+ }
68
+ interface ForgotPasswordRequest {
69
+ email: string;
70
+ tenantId?: string;
71
+ }
72
+ interface ResetPasswordRequest {
73
+ token: string;
74
+ newPassword: string;
75
+ }
76
+ interface RawAuthLoginResponse {
77
+ access_token?: string;
78
+ refresh_token?: string;
79
+ id_token?: string;
80
+ expires_in?: number;
81
+ token_type?: string;
82
+ scope?: string;
83
+ [key: string]: unknown;
84
+ }
85
+ /**
86
+ * Thin HTTP client for the IdentityService auth surface.
87
+ *
88
+ * Endpoint paths match the backend task `auth-password-reset-backend.md`:
89
+ *
90
+ * - `POST /auth/verify-otp`
91
+ * - `POST /auth/login` (password)
92
+ * - `POST /auth/logout` and `POST /auth/logout?everywhere=true`
93
+ * - `POST /auth/refresh-cookie` (web cookie flow)
94
+ * - `POST /auth/forgot-password`
95
+ * - `POST /auth/reset-password`
96
+ * - `GET /me/sessions`
97
+ * - `POST /me/sessions/{id}/revoke`
98
+ *
99
+ * Doesn't touch token storage — that's `AuthClient`'s job. Doesn't decide
100
+ * what to do with errors — callers handle them. Just builds requests and
101
+ * deserialises responses.
102
+ */
103
+ declare class AuthApiClient {
104
+ private readonly http;
105
+ private readonly baseUrl;
106
+ private readonly getAccessToken;
107
+ private readonly useCredentials;
108
+ constructor(options: AuthApiClientOptions);
109
+ loginWithOtp(request: OtpLoginRequest): Promise<RawAuthLoginResponse>;
110
+ loginWithPassword(request: PasswordLoginRequest): Promise<RawAuthLoginResponse>;
111
+ /** Web cookie-flow refresh. Sends no body; cookie travels via `credentials`. */
112
+ refreshCookie(): Promise<RawAuthLoginResponse>;
113
+ logout(everywhere?: boolean): Promise<void>;
114
+ forgotPassword(request: ForgotPasswordRequest): Promise<void>;
115
+ resetPassword(request: ResetPasswordRequest): Promise<void>;
116
+ listSessions(): Promise<AuthSessionInfo[]>;
117
+ revokeSession(sessionId: string): Promise<void>;
118
+ private postLogin;
119
+ private authHeaders;
120
+ }
121
+
122
+ /**
123
+ * Configuration for {@link AuthClient}.
124
+ *
125
+ * `realm` and `clientId` are **required** and never have sensible portfolio-wide
126
+ * defaults. Each consumer (Questioner web, OnlineMenu web, future apps) supplies
127
+ * its own values. This is the contract that enables the cross-realm hard wall
128
+ * planned in Phase 2 of the product split.
129
+ */
130
+ interface AuthClientConfig {
131
+ /** Keycloak base URL, e.g. `https://identity.dloizides.com`. Trailing slash optional. */
132
+ baseUrl: string;
133
+ /** Realm name, e.g. `OnlineMenu` or `Questioner`. NEVER hardcoded. */
134
+ realm: string;
135
+ /** OAuth client id registered in the realm. */
136
+ clientId: string;
137
+ /** Where Keycloak should redirect after authorization (PKCE / authorization-code flow). */
138
+ redirectUri?: string;
139
+ /** Space-separated OAuth scopes. Defaults to `openid profile email`. */
140
+ scope?: string;
141
+ }
142
+
143
+ /**
144
+ * Persisted token bundle.
145
+ *
146
+ * `expiresAt` is an absolute UNIX millisecond timestamp computed at
147
+ * acquisition time (`Date.now() + expires_in * 1000`) so consumers can
148
+ * trivially compare against `Date.now()` without re-deriving an expiry clock.
149
+ */
150
+ interface AuthTokens {
151
+ accessToken: string;
152
+ refreshToken?: string;
153
+ idToken?: string;
154
+ /** Absolute UNIX millis. `0` or missing means unknown — treat as expired. */
155
+ expiresAt: number;
156
+ }
157
+
158
+ /**
159
+ * Pluggable token persistence.
160
+ *
161
+ * Consumers (web, native, server-side) provide an implementation tailored to
162
+ * their platform: `localStorage`, `sessionStorage`, `expo-secure-store`,
163
+ * `AsyncStorage`, or an in-memory map for tests. Keeping the interface narrow
164
+ * (read / write / clear) keeps the package transport-agnostic.
165
+ */
166
+ interface TokenStorage {
167
+ read(): Promise<AuthTokens | null>;
168
+ write(tokens: AuthTokens): Promise<void>;
169
+ clear(): Promise<void>;
170
+ }
171
+
172
+ /**
173
+ * Pluggable persistence for the `lastRefreshedAt` timestamp.
174
+ *
175
+ * Decoupled from `TokenStorage` so consumers can pick a different backend
176
+ * (e.g., write through `AsyncStorage` on RN where the secure store would
177
+ * gate every read on biometric).
178
+ */
179
+ interface InactivityStore {
180
+ read(): Promise<number | null>;
181
+ write(timestamp: number): Promise<void>;
182
+ clear(): Promise<void>;
183
+ }
184
+ interface InactivityTrackerOptions {
185
+ store: InactivityStore;
186
+ /**
187
+ * Maximum days the user can be inactive (no successful refresh) before
188
+ * sessions are forcibly cleared. Default 90 (matches mobile decision).
189
+ */
190
+ maxInactivityDays?: number;
191
+ /** Inject for tests; defaults to `Date.now`. */
192
+ now?: () => number;
193
+ }
194
+ /**
195
+ * Tracks the last time a refresh succeeded and decides whether the session
196
+ * has aged past its inactivity threshold.
197
+ *
198
+ * - `markActive(now?)` is called by `RefreshInterceptor` after every
199
+ * successful token swap.
200
+ * - `isExpired()` is called from `AuthClient.init()` at boot. If true,
201
+ * consumers clear tokens and emit `sessionExpired`.
202
+ *
203
+ * Choosing days (not e.g. minutes) makes the policy match what users
204
+ * understand: a session left untouched for 90 days needs re-auth.
205
+ */
206
+ declare class InactivityTracker {
207
+ private readonly store;
208
+ private readonly maxInactivityMs;
209
+ private readonly now;
210
+ constructor(options: InactivityTrackerOptions);
211
+ markActive(timestamp?: number): Promise<void>;
212
+ getLastActive(): Promise<number | null>;
213
+ isExpired(): Promise<boolean>;
214
+ clear(): Promise<void>;
215
+ }
216
+
217
+ /**
218
+ * The pluggable refresh function the interceptor calls when an access token
219
+ * is missing or expired. Implementations differ per transport:
220
+ *
221
+ * - **Mobile (SecureStore)**: posts to `/auth/refresh` with the refresh token
222
+ * from `AuthTokens.refreshToken`.
223
+ * - **Web (Cookie)**: posts to `/auth/refresh-cookie` with `credentials:
224
+ * 'include'` — the refresh token rides on the httpOnly cookie; `current`
225
+ * carries only the access token (refresh token will be undefined).
226
+ *
227
+ * Returns the new token bundle, or `null` when refresh failed in a way that
228
+ * means "session over" (e.g., 401 from the auth server).
229
+ */
230
+ type RefreshFn = (current: AuthTokens | null) => Promise<AuthTokens | null>;
231
+ interface RefreshInterceptorOptions {
232
+ storage: TokenStorage;
233
+ refresh: RefreshFn;
234
+ events: AuthEventEmitter;
235
+ /**
236
+ * Optional callback fired AFTER tokens are persisted on a successful
237
+ * refresh. Used by `AuthClient` to update the inactivity tracker.
238
+ */
239
+ onRefreshSuccess?: (tokens: AuthTokens) => Promise<void> | void;
240
+ }
241
+ /**
242
+ * Coordinates refresh-token swaps so concurrent 401s don't trigger N parallel
243
+ * refreshes. The first caller to hit `refreshTokens()` while no refresh is
244
+ * already in flight wins the role of "refresher"; everyone else awaits the
245
+ * same promise.
246
+ *
247
+ * On failure, storage is cleared and `sessionExpired` is emitted exactly once
248
+ * per refresh attempt.
249
+ */
250
+ declare class RefreshInterceptor {
251
+ private readonly storage;
252
+ private readonly refresh;
253
+ private readonly events;
254
+ private readonly onRefreshSuccess;
255
+ private inflight;
256
+ constructor(options: RefreshInterceptorOptions);
257
+ /**
258
+ * Trigger (or join) a refresh. Returns the new tokens, or `null` if the
259
+ * refresh failed — in which case storage has already been cleared and
260
+ * `sessionExpired` already fired.
261
+ */
262
+ refreshTokens(): Promise<AuthTokens | null>;
263
+ /**
264
+ * Whether a refresh is currently in flight. Exposed for tests / debug.
265
+ */
266
+ get isRefreshing(): boolean;
267
+ private runRefresh;
268
+ private failHard;
269
+ }
270
+
271
+ /**
272
+ * Inputs to {@link AuthClient.fromIssuerUrl}.
273
+ *
274
+ * Used by consumers that store only an issuer URL and want to derive `realm`
275
+ * + `baseUrl` rather than configure them separately.
276
+ */
277
+ interface AuthClientFromIssuerInput {
278
+ issuerUrl: string;
279
+ clientId: string;
280
+ redirectUri?: string;
281
+ scope?: string;
282
+ }
283
+ /**
284
+ * Optional collaborators wired into {@link AuthClient} for the v2 surface.
285
+ *
286
+ * - `api` enables `loginWith*`, `logout`, `requestPasswordReset`,
287
+ * `confirmPasswordReset`. Without it, those methods throw.
288
+ * - `interceptor` enables `init()` to silently refresh tokens at boot, and is
289
+ * used by `loginWithOtp/Password` to mark inactivity-active.
290
+ * - `inactivityTracker` enforces the 90-day timeout at `init()`.
291
+ *
292
+ * Consumers can omit any/all of these — the v1 PKCE / token-storage surface
293
+ * keeps working unchanged.
294
+ */
295
+ interface AuthClientCollaborators {
296
+ api?: AuthApiClient;
297
+ interceptor?: RefreshInterceptor;
298
+ inactivityTracker?: InactivityTracker;
299
+ events?: AuthEventEmitter;
300
+ /**
301
+ * Observability hook fired when a fresh token bundle has been acquired
302
+ * (any login path: OTP, password, or direct-KC PKCE). For app-side
303
+ * analytics/logging only — NOT for BFF integration (Phase 2 designs that
304
+ * fresh).
305
+ */
306
+ onTokenAcquired?: (tokens: AuthTokens) => void;
307
+ /**
308
+ * Observability hook fired when an existing token bundle has been
309
+ * refreshed. For app-side analytics/logging only.
310
+ */
311
+ onTokenRefreshed?: (tokens: AuthTokens) => void;
312
+ }
313
+ /**
314
+ * Direct-to-KC (PKCE) routing flag added in v2.1.0.
315
+ *
316
+ * When `true`, `AuthClient` consumers can route their PKCE auth code through
317
+ * the shared OIDC primitives (`exchangeAuthorizationCodeViaOidc`,
318
+ * `refreshTokensViaOidc`) instead of the proxied identity-api `/auth/login`
319
+ * + `/auth/refresh` flow.
320
+ *
321
+ * Default `false` — v2.0 behavior unchanged.
322
+ *
323
+ * The flag is read-only at runtime (`isDirectMode()`) so apps can render
324
+ * conditionally on whether they've opted in.
325
+ */
326
+ interface DirectKcOptions {
327
+ useDirectKcAuth?: boolean;
328
+ }
329
+ interface LoginOptions {
330
+ /** When true, request `offline_access` scope so the IdP issues a long-lived refresh token. */
331
+ offlineAccess?: boolean;
332
+ }
333
+ interface LogoutOptions {
334
+ /** Revoke all sessions on the IdP, not just the current one. */
335
+ everywhere?: boolean;
336
+ }
337
+ declare class AuthClient {
338
+ private readonly config;
339
+ private readonly directKcAuth;
340
+ private readonly tokenStorage;
341
+ private readonly api;
342
+ private readonly interceptor;
343
+ private readonly inactivityTracker;
344
+ private readonly events;
345
+ private readonly onTokenAcquired;
346
+ private readonly onTokenRefreshed;
347
+ /**
348
+ * @throws Error when `baseUrl`, `realm`, or `clientId` is missing or empty.
349
+ */
350
+ constructor(config: AuthClientConfig & DirectKcOptions, storage: TokenStorage, collaborators?: AuthClientCollaborators);
351
+ /**
352
+ * Whether this client is configured to route auth flows directly to
353
+ * Keycloak (v2.1.0 direct-KC path) instead of through the proxied
354
+ * identity-api `/auth/*` endpoints.
355
+ *
356
+ * Apps can render conditionally on this — e.g. to swap a login form for
357
+ * a "Sign in with Keycloak" redirect button.
358
+ */
359
+ isDirectMode(): boolean;
360
+ /**
361
+ * Persist a token bundle produced by an external flow (e.g. the
362
+ * app-side `useKeycloakExchange` hook that consumes the shared
363
+ * `exchangeAuthorizationCode` primitive). Fires `onTokenAcquired` after
364
+ * persistence and marks the inactivity tracker active.
365
+ *
366
+ * Designed for the v2.1.0 direct-KC path where the PKCE code exchange
367
+ * happens in the app's React-Query hook (which needs `useDispatch`/etc.)
368
+ * but the token persistence + observability should still flow through
369
+ * the shared client.
370
+ */
371
+ acceptDirectKcTokens(response: TokenResponse): Promise<AuthTokens>;
372
+ /**
373
+ * Same as {@link acceptDirectKcTokens} but fires `onTokenRefreshed`.
374
+ * Use after a `refreshAccessToken()` swap to keep observability counts
375
+ * separated between "fresh login" and "silent refresh".
376
+ */
377
+ acceptDirectKcRefresh(response: TokenResponse): Promise<AuthTokens>;
378
+ /**
379
+ * Build an {@link AuthClient} from a standalone issuer URL by parsing the
380
+ * realm and base URL. Useful when migrating from the legacy
381
+ * `KEYCLOAK_ISSUER` env var convention.
382
+ *
383
+ * @throws Error when the issuer URL doesn't match `{base}/realms/{realm}`.
384
+ */
385
+ static fromIssuerUrl(input: AuthClientFromIssuerInput, storage: TokenStorage, collaborators?: AuthClientCollaborators): AuthClient;
386
+ private static validateConfig;
387
+ get realm(): string;
388
+ get clientId(): string;
389
+ get baseUrl(): string;
390
+ get scope(): string;
391
+ get redirectUri(): string | undefined;
392
+ /** Issuer URL: `{baseUrl}/realms/{realm}`. */
393
+ get issuerUrl(): string;
394
+ get authorizationEndpoint(): string;
395
+ get tokenEndpoint(): string;
396
+ get userInfoEndpoint(): string;
397
+ get logoutEndpoint(): string;
398
+ /**
399
+ * Build a fully-formed authorization URL the user agent can navigate to.
400
+ *
401
+ * @throws Error when `redirectUri` is not configured.
402
+ */
403
+ buildAuthorizationUrl(input?: {
404
+ state?: string;
405
+ codeChallenge?: string;
406
+ codeChallengeMethod?: 'S256' | 'plain';
407
+ offlineAccess?: boolean;
408
+ }): string;
409
+ getTokens(): Promise<AuthTokens | null>;
410
+ setTokens(tokens: AuthTokens): Promise<void>;
411
+ clearTokens(): Promise<void>;
412
+ /**
413
+ * Read the current access token if it exists and is not expired.
414
+ * Returns `null` for "no usable token".
415
+ */
416
+ getAccessToken(now?: number): Promise<string | null>;
417
+ /** Subscribe to lifecycle events (currently `sessionExpired` only). */
418
+ on(event: AuthEventName, listener: AuthEventListener): AuthEventUnsubscribe;
419
+ /**
420
+ * Boot-time wiring. Checks the inactivity tracker; if expired, clears
421
+ * tokens and emits `sessionExpired`. Returns whether a usable session
422
+ * survived.
423
+ */
424
+ init(): Promise<{
425
+ hasSession: boolean;
426
+ }>;
427
+ /**
428
+ * Trigger a refresh via the configured interceptor. Returns the new tokens
429
+ * or `null` when the refresh failed (in which case `sessionExpired` has
430
+ * already fired).
431
+ *
432
+ * @throws Error when no interceptor is configured.
433
+ */
434
+ refresh(): Promise<AuthTokens | null>;
435
+ loginWithOtp(input: {
436
+ email: string;
437
+ otp: string;
438
+ tenantId?: string;
439
+ } & LoginOptions): Promise<AuthTokens>;
440
+ loginWithPassword(input: {
441
+ email: string;
442
+ password: string;
443
+ tenantId?: string;
444
+ } & LoginOptions): Promise<AuthTokens>;
445
+ logout(options?: LogoutOptions): Promise<void>;
446
+ requestPasswordReset(input: {
447
+ email: string;
448
+ tenantId?: string;
449
+ }): Promise<void>;
450
+ confirmPasswordReset(input: {
451
+ token: string;
452
+ newPassword: string;
453
+ }): Promise<void>;
454
+ /** Internal: run a login HTTP call, persist tokens, mark inactivity-active. */
455
+ private runLogin;
456
+ private requireApi;
457
+ private resolveScope;
458
+ }
459
+
460
+ export { AuthApiClient as A, type DirectKcOptions as D, type ForgotPasswordRequest as F, type InactivityStore as I, type LoginOptions as L, type OtpLoginRequest as O, type PasswordLoginRequest as P, type ResetPasswordRequest as R, type TokenStorage as T, type AuthSessionInfo as a, AuthClient as b, type AuthTokens as c, type AuthApiClientOptions as d, type AuthClientCollaborators as e, type AuthClientConfig as f, type AuthClientFromIssuerInput as g, AuthEventEmitter as h, type AuthEventListener as i, type AuthEventName as j, type AuthEventUnsubscribe as k, InactivityTracker as l, type InactivityTrackerOptions as m, type LogoutOptions as n, type RawAuthLoginResponse as o, type RefreshFn as p, RefreshInterceptor as q, type RefreshInterceptorOptions as r };