@forward-software/react-auth 1.1.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/src/auth.tsx ADDED
@@ -0,0 +1,546 @@
1
+ import React, { createContext, useContext, useEffect, useState } from 'react';
2
+ import type { PropsWithChildren } from 'react';
3
+ import { useSyncExternalStore } from 'use-sync-external-store/shim';
4
+
5
+ import { createEventEmitter, Deferred, EventReceiver } from "./utils";
6
+ import type { EventKey } from "./utils";
7
+
8
+ /**
9
+ * Represents authentication tokens used for API authorization
10
+ */
11
+ type AuthTokens = {};
12
+
13
+ /**
14
+ * Represents user credentials used for authentication
15
+ */
16
+ type AuthCredentials = {};
17
+
18
+ /**
19
+ * Maps authentication events to their corresponding payload types
20
+ * @template E - The error type used throughout the authentication flow
21
+ */
22
+ type AuthEventsMap<E extends Error> = {
23
+ initSuccess: undefined;
24
+
25
+ initFailed: E;
26
+
27
+ loginStarted: undefined;
28
+
29
+ loginSuccess: undefined;
30
+
31
+ loginFailed: E;
32
+
33
+ refreshStarted: undefined;
34
+
35
+ refreshSuccess: undefined;
36
+
37
+ refreshFailed: E;
38
+
39
+ logoutStarted: undefined;
40
+
41
+ logoutSuccess: undefined;
42
+
43
+ logoutFailed: E;
44
+ };
45
+
46
+ /**
47
+ * Function type for subscription callbacks
48
+ */
49
+ type SubscribeFn = () => void;
50
+
51
+ /**
52
+ * Function type for unsubscribing from events
53
+ * @returns {boolean} - Returns true if the subscription was successfully removed
54
+ */
55
+ type UnsubscribeFn = () => boolean;
56
+
57
+ /**
58
+ * Interface defining the core authentication client functionality
59
+ * @template T - The type of authentication tokens
60
+ * @template C - The type of authentication credentials
61
+ */
62
+ export interface AuthClient<T = AuthTokens, C = AuthCredentials> {
63
+ /**
64
+ * Optional initialization hook called before authentication
65
+ * @returns {Promise<T | null>} - Returns authentication tokens if available
66
+ */
67
+ onInit?(): Promise<T | null>;
68
+
69
+ /**
70
+ * Optional post-initialization hook
71
+ */
72
+ onPostInit?(): Promise<void>;
73
+
74
+ /**
75
+ * Optional pre-login hook
76
+ */
77
+ onPreLogin?(): Promise<void>;
78
+
79
+ /**
80
+ * Handles the login process
81
+ * @param {C} [credentials] - Optional credentials for authentication
82
+ * @returns {Promise<T>} - Returns authentication tokens upon successful login
83
+ */
84
+ onLogin(credentials?: C): Promise<T>;
85
+
86
+ /**
87
+ * Optional post-login hook
88
+ * @param {boolean} isSuccess - Indicates whether the login was successful
89
+ */
90
+ onPostLogin?(isSuccess: boolean): Promise<void>;
91
+
92
+ /**
93
+ * Optional pre-refresh hook
94
+ */
95
+ onPreRefresh?(): Promise<void>;
96
+
97
+ /**
98
+ * Optional token refresh handler.
99
+ * Implement this method to handle token refresh logic.
100
+ * @param {T} currentTokens - The current authentication tokens.
101
+ * @param {number} [minValidity] - Optional minimum token validity period in seconds.
102
+ * @returns {Promise<T>} - A promise that resolves with the refreshed authentication tokens.
103
+ */
104
+ onRefresh?(currentTokens: T, minValidity?: number): Promise<T>;
105
+
106
+ /**
107
+ * Optional post-refresh hook
108
+ * @param {boolean} isSuccess - Indicates whether the token refresh was successful
109
+ */
110
+ onPostRefresh?(isSuccess: boolean): Promise<void>;
111
+
112
+ /**
113
+ * Optional pre-logout hook
114
+ */
115
+ onPreLogout?(): Promise<void>;
116
+
117
+ /**
118
+ * Optional logout handler
119
+ */
120
+ onLogout?(): Promise<void>;
121
+
122
+ /**
123
+ * Optional post-logout hook
124
+ * @param {boolean} isSuccess - Indicates whether the logout was successful
125
+ */
126
+ onPostLogout?(isSuccess: boolean): Promise<void>;
127
+ }
128
+
129
+ /**
130
+ * Extracts token type from an AuthClient implementation
131
+ * @template AC - The AuthClient implementation type
132
+ */
133
+ type AuthClientTokens<AC extends AuthClient> = Partial<Awaited<ReturnType<AC["onLogin"]>>>;
134
+
135
+ /**
136
+ * Extracts credentials type from an AuthClient implementation
137
+ * @template AC - The AuthClient implementation type
138
+ */
139
+ type AuthClientCredentials<AC extends AuthClient> = Parameters<AC["onLogin"]>;
140
+
141
+ /**
142
+ * Represents the current state of an AuthClient
143
+ * @template AC - The AuthClient implementation type
144
+ */
145
+ type AuthClientState<AC extends AuthClient> = {
146
+ isAuthenticated: boolean;
147
+
148
+ isInitialized: boolean;
149
+
150
+ tokens: AuthClientTokens<AC>;
151
+ };
152
+
153
+
154
+ class AuthClientEnhancements<AC extends AuthClient, E extends Error> {
155
+
156
+ private _state: Readonly<AuthClientState<AC>> = {
157
+ isAuthenticated: false,
158
+ isInitialized: false,
159
+ tokens: {},
160
+ };
161
+
162
+ // refresh queue - used to avoid concurrency issue during Token refresh
163
+ private refreshQ: Array<Deferred<boolean>> = [];
164
+
165
+ private eventEmitter = createEventEmitter<AuthEventsMap<E>>();
166
+
167
+ private subscribers: Set<SubscribeFn> = new Set();
168
+
169
+ private _authClient: AC;
170
+
171
+ constructor(authClient: AC) {
172
+ this._authClient = authClient;
173
+ }
174
+
175
+ //
176
+ // Getters
177
+ //
178
+
179
+ /**
180
+ * Indicates whether the authentication client has been initialized
181
+ * @readonly
182
+ */
183
+ public get isInitialized() {
184
+ return this._state.isInitialized;
185
+ }
186
+
187
+ /**
188
+ * Indicates whether the user is currently authenticated
189
+ * @readonly
190
+ */
191
+ public get isAuthenticated() {
192
+ return this._state.isAuthenticated;
193
+ }
194
+
195
+ /**
196
+ * Current authentication tokens
197
+ * @readonly
198
+ */
199
+ public get tokens() {
200
+ return this._state.tokens;
201
+ }
202
+
203
+ /**
204
+ * Initializes the authentication client
205
+ * @returns {Promise<boolean>} - Returns true if initialization was successful
206
+ */
207
+ public async init(): Promise<boolean> {
208
+ try {
209
+ const prevTokens = await this._authClient.onInit?.();
210
+
211
+ this.setState({
212
+ isInitialized: true,
213
+ isAuthenticated: !!prevTokens,
214
+ tokens: prevTokens || {},
215
+ });
216
+
217
+ this.emit("initSuccess", undefined);
218
+ } catch (error) {
219
+ this.setState({
220
+ isInitialized: false,
221
+ });
222
+
223
+ this.emit("initFailed", error as E);
224
+ }
225
+
226
+ await this._authClient.onPostInit?.();
227
+
228
+ return this.isInitialized;
229
+ }
230
+
231
+ /**
232
+ * Attempts to authenticate the user with provided credentials
233
+ * @param {...AuthClientCredentials<AC>} params - Authentication credentials
234
+ * @returns {Promise<boolean>} - Returns true if login was successful
235
+ */
236
+ public async login(...params: AuthClientCredentials<AC>): Promise<boolean> {
237
+ this.emit("loginStarted", undefined);
238
+
239
+ await this._authClient.onPreLogin?.();
240
+
241
+ let isSuccess: boolean = false;
242
+
243
+ try {
244
+ const tokens = await this._authClient.onLogin(...params);
245
+
246
+ this.setState({
247
+ isAuthenticated: !!tokens,
248
+ tokens,
249
+ });
250
+
251
+ this.emit("loginSuccess", undefined);
252
+
253
+ isSuccess = true;
254
+ } catch (err) {
255
+ this.setState({
256
+ isAuthenticated: false,
257
+ tokens: {},
258
+ });
259
+
260
+ this.emit("loginFailed", err as E);
261
+
262
+ isSuccess = false;
263
+ }
264
+
265
+ await this._authClient.onPostLogin?.(isSuccess);
266
+
267
+ return this.isAuthenticated;
268
+ }
269
+
270
+ /**
271
+ * Refreshes the authentication tokens
272
+ * @param {number} [minValidity] - Minimum token validity period in seconds
273
+ * @returns {Promise<boolean>} - Returns true if token refresh was successful
274
+ */
275
+ public async refresh(minValidity?: number): Promise<boolean> {
276
+ const deferred = new Deferred<boolean>();
277
+
278
+ this.runRefresh(deferred, minValidity);
279
+
280
+ return deferred.getPromise();
281
+ }
282
+
283
+ /**
284
+ * Logs out the current user
285
+ * @returns {Promise<void>}
286
+ */
287
+ public async logout(): Promise<void> {
288
+ this.emit("logoutStarted", undefined);
289
+
290
+ await this._authClient.onPreLogout?.();
291
+
292
+ let isSuccess: boolean = false;
293
+
294
+ try {
295
+ await this._authClient.onLogout?.();
296
+
297
+ this.setState({
298
+ isAuthenticated: false,
299
+ tokens: {},
300
+ });
301
+
302
+ this.emit("logoutSuccess", undefined);
303
+
304
+ isSuccess = true;
305
+ } catch (err) {
306
+ this.emit("logoutFailed", err as E);
307
+
308
+ isSuccess = false;
309
+ }
310
+
311
+ await this._authClient.onPostLogout?.(isSuccess);
312
+ }
313
+
314
+ /**
315
+ * Registers an event listener for authentication events
316
+ * @template K - The event key type
317
+ * @param {K} eventName - The name of the event to listen for
318
+ * @param {EventReceiver<AuthEventsMap<E>[K]>} listener - The event handler function
319
+ */
320
+ public on<K extends EventKey<AuthEventsMap<E>>>(eventName: K, listener: EventReceiver<AuthEventsMap<E>[K]>): void {
321
+ this.eventEmitter.on(eventName, listener);
322
+ }
323
+
324
+ /**
325
+ * Removes an event listener for authentication events
326
+ * @template K - The event key type
327
+ * @param {K} eventName - The name of the event to stop listening for
328
+ * @param {EventReceiver<AuthEventsMap<E>[K]>} listener - The event handler function to remove
329
+ */
330
+ public off<K extends EventKey<AuthEventsMap<E>>>(eventName: K, listener: EventReceiver<AuthEventsMap<E>[K]>): void {
331
+ this.eventEmitter.off(eventName, listener);
332
+ }
333
+
334
+ /**
335
+ * Subscribes to authentication state changes
336
+ * @param {SubscribeFn} subscription - The callback function to be called on state changes
337
+ * @returns {UnsubscribeFn} - A function to unsubscribe from state changes
338
+ */
339
+ // Should be declared like this to avoid binding issues when used by useSyncExternalStore
340
+ public subscribe = (subscription: SubscribeFn): UnsubscribeFn => {
341
+ this.subscribers.add(subscription);
342
+
343
+ return () => this.subscribers.delete(subscription);
344
+ };
345
+
346
+
347
+ /**
348
+ * Gets the current authentication state
349
+ * @returns {AuthClientState<AC>} - The current authentication state
350
+ */
351
+ // Should be declared like this to avoid binding issues when used by useSyncExternalStore
352
+ public getSnapshot = (): AuthClientState<AC> => {
353
+ return this._state;
354
+ };
355
+
356
+ //
357
+ // Private methods
358
+ //
359
+
360
+ private setState(stateUpdate: Partial<AuthClientState<AC>>): void {
361
+ this._state = {
362
+ ...this._state,
363
+ ...stateUpdate,
364
+ };
365
+
366
+ this.notifySubscribers();
367
+ }
368
+
369
+ private async runRefresh(deferred: Deferred<boolean>, minValidity?: number): Promise<void> {
370
+ // Add deferred Promise to refresh queue
371
+ this.refreshQ.push(deferred);
372
+
373
+ // If refresh queue already has promises enqueued do not attempt a new refresh - one is already in progress
374
+ if (this.refreshQ.length !== 1) {
375
+ return;
376
+ }
377
+
378
+ this.emit("refreshStarted", undefined);
379
+
380
+ await this._authClient.onPreRefresh?.();
381
+
382
+ let isAuthenticated: boolean = false;
383
+ let tokens: AuthClientTokens<AC> = {};
384
+
385
+ try {
386
+ tokens = (await this._authClient.onRefresh?.(this.tokens, minValidity)) ?? {};
387
+ isAuthenticated = true;
388
+
389
+ this.emit("refreshSuccess", undefined);
390
+ } catch (err) {
391
+ isAuthenticated = false;
392
+
393
+ this.emit("refreshFailed", err as E);
394
+ }
395
+
396
+ this.setState({
397
+ isAuthenticated,
398
+ tokens,
399
+ });
400
+
401
+ await this._authClient.onPostRefresh?.(isAuthenticated);
402
+
403
+ for (let p = this.refreshQ.pop(); p != null; p = this.refreshQ.pop()) {
404
+ p.resolve(isAuthenticated);
405
+ }
406
+ }
407
+
408
+ private emit<K extends EventKey<AuthEventsMap<E>>>(eventName: K, error: AuthEventsMap<E>[K]): void {
409
+ this.eventEmitter.emit(eventName, error);
410
+ }
411
+
412
+ private notifySubscribers() {
413
+ this.subscribers.forEach((s) => {
414
+ try {
415
+ s();
416
+ } catch { }
417
+ });
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Enhanced authentication client with additional functionality and state management
423
+ * @template AC - The AuthClient implementation type
424
+ * @template E - The error type used throughout the authentication flow
425
+ */
426
+ export type EnhancedAuthClient<AC extends AuthClient, E extends Error> = AC & AuthClientEnhancements<AC, E>;
427
+
428
+ /**
429
+ * Wraps a basic AuthClient implementation with enhanced functionality
430
+ * @template AC - The AuthClient implementation type
431
+ * @template E - The error type used throughout the authentication flow
432
+ * @param {AC} authClient - The base authentication client to enhance
433
+ * @returns {EnhancedAuthClient<AC, E>} - An enhanced authentication client with additional features
434
+ */
435
+ export function wrapAuthClient<AC extends AuthClient, E extends Error = Error>(authClient: AC): EnhancedAuthClient<AC, E> {
436
+ Object.setPrototypeOf(AuthClientEnhancements.prototype, authClient);
437
+
438
+ return new AuthClientEnhancements<AC, E>(authClient) as unknown as EnhancedAuthClient<AC, E>;
439
+ }
440
+
441
+ /**
442
+ * Represents the current state of the authentication provider
443
+ */
444
+ type AuthProviderState = {
445
+ isAuthenticated: boolean;
446
+ isInitialized: boolean;
447
+ };
448
+
449
+ /**
450
+ * The authentication context containing both the state and the enhanced auth client
451
+ * @template AC - The AuthClient implementation type
452
+ * @template E - The error type used throughout the authentication flow
453
+ */
454
+ type AuthContext<AC extends AuthClient, E extends Error> = AuthProviderState & {
455
+ authClient: EnhancedAuthClient<AC, E>;
456
+ };
457
+
458
+ /**
459
+ * Props that can be passed to AuthProvider
460
+ */
461
+ export type AuthProviderProps = PropsWithChildren<{
462
+ /**
463
+ * An optional component to display if AuthClient initialization failed.
464
+ */
465
+ ErrorComponent?: React.ReactNode;
466
+
467
+ /**
468
+ * An optional component to display while AuthClient instance is being initialized.
469
+ */
470
+ LoadingComponent?: React.ReactNode;
471
+ }>;
472
+
473
+ /**
474
+ * Creates an authentication context and provider for a React application.
475
+ * It wraps the provided `authClient` with enhanced state management and event handling.
476
+ *
477
+ * @template AC - The type of the base `AuthClient` implementation.
478
+ * @template E - The type of error expected during authentication flows. Defaults to `Error`.
479
+ * @param {AC} authClient - The base authentication client instance to use.
480
+ * @returns An object containing:
481
+ * - `AuthProvider`: A React component to wrap the application or parts of it.
482
+ * - `authClient`: The enhanced authentication client instance.
483
+ * - `useAuthClient`: A hook to access the enhanced `authClient` within the `AuthProvider`.
484
+ */
485
+ export function createAuth<AC extends AuthClient, E extends Error = Error>(authClient: AC) {
486
+ // Create a React context containing an AuthClient instance.
487
+ const authContext = createContext<AuthContext<AC, E> | null>(null);
488
+
489
+ const enhancedAuthClient = wrapAuthClient<AC, E>(authClient);
490
+
491
+ // Create the React Context Provider for the AuthClient instance.
492
+ const AuthProvider: React.FC<AuthProviderProps> = ({ children, ErrorComponent, LoadingComponent }) => {
493
+ const [isInitFailed, setInitFailed] = useState(false);
494
+ const { isAuthenticated, isInitialized } = useSyncExternalStore(enhancedAuthClient.subscribe, enhancedAuthClient.getSnapshot);
495
+
496
+ useEffect(() => {
497
+ async function initAuthClient() {
498
+ // Call init function
499
+ const initSuccess = await enhancedAuthClient.init();
500
+ setInitFailed(!initSuccess);
501
+ }
502
+
503
+ // Init AuthClient
504
+ initAuthClient();
505
+ }, []);
506
+
507
+ if (!!ErrorComponent && isInitFailed) {
508
+ return ErrorComponent;
509
+ }
510
+
511
+ if (!!LoadingComponent && !isInitialized) {
512
+ return LoadingComponent;
513
+ }
514
+
515
+ return (
516
+ <authContext.Provider
517
+ value={{
518
+ authClient: enhancedAuthClient,
519
+ isAuthenticated,
520
+ isInitialized,
521
+ }}
522
+ >
523
+ {children}
524
+ </authContext.Provider>
525
+ );
526
+ };
527
+
528
+ /**
529
+ * Hook to access the authentication client within the AuthProvider
530
+ * @throws Error if used outside of an AuthProvider
531
+ */
532
+ const useAuthClient = function (): EnhancedAuthClient<AC, E> {
533
+ const ctx = useContext(authContext);
534
+ if (!ctx) {
535
+ throw new Error('useAuthClient hook should be used inside AuthProvider');
536
+ }
537
+
538
+ return ctx.authClient;
539
+ };
540
+
541
+ return {
542
+ AuthProvider,
543
+ authClient: enhancedAuthClient,
544
+ useAuthClient,
545
+ };
546
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { createAuth } from "./auth";
2
+ export type { AuthClient } from "./auth";
@@ -1,3 +1,26 @@
1
+ // DEFERRED
2
+
3
+ export class Deferred<T> {
4
+ private promise: Promise<T>;
5
+
6
+ public resolve!: (value: T | PromiseLike<T>) => void;
7
+
8
+ public reject!: (reason?: any) => void;
9
+
10
+ constructor() {
11
+ this.promise = new Promise<T>((resolve, reject) => {
12
+ this.reject = reject;
13
+ this.resolve = resolve;
14
+ });
15
+ }
16
+
17
+ public getPromise(): Promise<T> {
18
+ return this.promise;
19
+ }
20
+ }
21
+
22
+ // EVENT EMITTER
23
+
1
24
  type EventsMap = Record<string, any>;
2
25
 
3
26
  export type EventKey<T extends EventsMap> = string & keyof T;
@@ -21,10 +44,10 @@ export function createEventEmitter<T extends EventsMap>(): Emitter<T> {
21
44
  listeners[key] = (listeners[key] || []).concat(fn);
22
45
  },
23
46
  off(key, fn) {
24
- listeners[key] = (listeners[key] || []).filter(f => f !== fn);
47
+ listeners[key] = (listeners[key] || []).filter((f) => f !== fn);
25
48
  },
26
49
  emit(key, data) {
27
- (listeners[key] || []).forEach(function(fn) {
50
+ (listeners[key] || []).forEach(function (fn) {
28
51
  try {
29
52
  fn(data);
30
53
  } catch {}