Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,65 @@ The `createAuth` function wraps your `AuthClient` implementation with an `Enhanc
- `subscribe(() => { })`, subscribe to AuthClient state changes
- `getSnapshot()`, returns the current state of the AuthClient

---

### Use multiple AuthClients

When your app needs to support multiple authentication providers simultaneously (e.g. username/password alongside Google Sign-In), use `createMultiAuth`:

```ts
import { createMultiAuth } from '@forward-software/react-auth';

export const { AuthProvider, authClients, useAuth } = createMultiAuth({
credentials: credentialsAuthClient,
google: googleAuthClient,
});
```

The `createMultiAuth` function accepts a map of `{ id: AuthClient }` pairs and returns:

- `AuthProvider`, the context Provider component that initialises **all** clients and provides access to them
- `authClients`, a map of enhanced authentication clients keyed by the IDs you provided
- `useAuth`, a hook that accepts a client ID and returns the corresponding enhanced auth client

#### AuthProvider

The same `LoadingComponent` and `ErrorComponent` props are supported. `LoadingComponent` is shown until **all** clients finish initializing. `ErrorComponent` is shown if **any** client's initialization fails.

```tsx
<AuthProvider
LoadingComponent={<Spinner />}
ErrorComponent={<ErrorPage />}
>
<App />
</AuthProvider>
```

#### useAuth

The `useAuth` hook is generic — the return type is automatically narrowed to the exact `EnhancedAuthClient` type for the key you provide:

```tsx
function MyComponent() {
// Each call is fully typed based on the key
const credentialsClient = useAuth('credentials');
const googleClient = useAuth('google');

return (
<>
<button onClick={() => credentialsClient.login({ username, password })}>
Sign in with credentials
</button>
<button onClick={() => googleClient.login()}>
Sign in with Google
</button>
</>
);
}
```

Each client provides the same `EnhancedAuthClient` interface described above.

## Examples

The [`examples`](https://github.com/forwardsoftware/react-auth/tree/main/examples) folder in the repository contains some examples of how you can integrate this library in your React app.
Expand Down
5 changes: 1 addition & 4 deletions lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,14 @@
"@types/node": "^25.2.3",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/use-sync-external-store": "^1.5.0",
"@vitejs/plugin-react": "catalog:",
"jsdom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
"dependencies": {
"use-sync-external-store": "^1.6.0"
},
"dependencies": {},
"peerDependencies": {
"react": ">=16.8"
}
Expand Down
162 changes: 106 additions & 56 deletions lib/src/auth.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import type { PropsWithChildren } from 'react';
import { useSyncExternalStore } from 'use-sync-external-store/shim';

import { createEventEmitter, Deferred, EventReceiver } from "./utils";
import type { EventKey } from "./utils";
Expand Down Expand Up @@ -433,27 +432,24 @@ export type EnhancedAuthClient<AC extends AuthClient, E extends Error> = AC & Au
* @returns {EnhancedAuthClient<AC, E>} - An enhanced authentication client with additional features
*/
export function wrapAuthClient<AC extends AuthClient, E extends Error = Error>(authClient: AC): EnhancedAuthClient<AC, E> {
Object.setPrototypeOf(AuthClientEnhancements.prototype, authClient);

return new AuthClientEnhancements<AC, E>(authClient) as unknown as EnhancedAuthClient<AC, E>;
}
// Build a per-instance prototype so that wrapping multiple clients does not cause
// one client's methods to bleed into another.
// Chain: instance → perInstanceProto (enhancement methods) → authClient (raw AC methods)
const perInstanceProto = Object.create(authClient);
Object.getOwnPropertyNames(AuthClientEnhancements.prototype)
.filter((name) => name !== 'constructor')
.forEach((name) => {
const descriptor = Object.getOwnPropertyDescriptor(AuthClientEnhancements.prototype, name);
if (descriptor) {
Object.defineProperty(perInstanceProto, name, descriptor);
}
});

/**
* Represents the current state of the authentication provider
*/
type AuthProviderState = {
isAuthenticated: boolean;
isInitialized: boolean;
};
const instance = new AuthClientEnhancements<AC, E>(authClient);
Object.setPrototypeOf(instance, perInstanceProto);

/**
* The authentication context containing both the state and the enhanced auth client
* @template AC - The AuthClient implementation type
* @template E - The error type used throughout the authentication flow
*/
type AuthContext<AC extends AuthClient, E extends Error> = AuthProviderState & {
authClient: EnhancedAuthClient<AC, E>;
};
return instance as unknown as EnhancedAuthClient<AC, E>;
}

/**
* Props that can be passed to AuthProvider
Expand All @@ -471,76 +467,130 @@ export type AuthProviderProps = PropsWithChildren<{
}>;

/**
* Creates an authentication context and provider for a React application.
* It wraps the provided `authClient` with enhanced state management and event handling.
* Creates an authentication context and provider supporting multiple auth clients.
* Each client is identified by a string key.
*
* @template AC - The type of the base `AuthClient` implementation.
* @template M - A map of client IDs to AuthClient implementations.
* @template E - The type of error expected during authentication flows. Defaults to `Error`.
* @param {AC} authClient - The base authentication client instance to use.
* @param {M} authClientsMap - A map of auth client IDs to their instances.
* @returns An object containing:
* - `AuthProvider`: A React component to wrap the application or parts of it.
* - `authClient`: The enhanced authentication client instance.
* - `useAuthClient`: A hook to access the enhanced `authClient` within the `AuthProvider`.
* - `authClients`: The map of enhanced authentication clients.
* - `useAuth`: A hook that accepts a client ID and returns the corresponding enhanced auth client.
*/
export function createAuth<AC extends AuthClient, E extends Error = Error>(authClient: AC) {
// Create a React context containing an AuthClient instance.
const authContext = createContext<AuthContext<AC, E> | null>(null);

const enhancedAuthClient = wrapAuthClient<AC, E>(authClient);

// Create the React Context Provider for the AuthClient instance.
export function createMultiAuth<M extends Record<string, AuthClient>, E extends Error = Error>(
authClientsMap: M,
) {
type EnhancedMap = { [K in keyof M]: EnhancedAuthClient<M[K], E> };

const enhancedClientsMap = (Object.keys(authClientsMap) as (keyof M)[]).reduce(
(acc, id) => {
acc[id] = wrapAuthClient<M[typeof id], E>(authClientsMap[id]);
return acc;
},
Object.create(null) as EnhancedMap,
);

const clientsList = (Object.keys(enhancedClientsMap) as (keyof M)[]).map(
(id) => enhancedClientsMap[id],
);

const multiAuthContext = createContext<EnhancedMap | null>(null);

// Create the React Context Provider for all AuthClient instances.
const AuthProvider: React.FC<AuthProviderProps> = ({ children, ErrorComponent, LoadingComponent }) => {
const [isInitFailed, setInitFailed] = useState(false);
const { isAuthenticated, isInitialized } = useSyncExternalStore(enhancedAuthClient.subscribe, enhancedAuthClient.getSnapshot);
const [initState, setInitState] = useState<{ allInitialized: boolean; failed: boolean }>({
allInitialized: clientsList.length === 0,
failed: false,
});

useEffect(() => {
async function initAuthClient() {
// Call init function
const initSuccess = await enhancedAuthClient.init();
setInitFailed(!initSuccess);
async function initAllClients() {
// Each client's init() is wrapped with .catch() so that a rejection from one client
// (e.g. an onPostInit error) does not short-circuit the others; all clients always
// get the chance to finish initializing before we update the provider state.
const results = await Promise.all(
clientsList.map((client) => client.init().catch((): boolean => false)),
);
setInitState({ allInitialized: true, failed: results.some((r) => !r) });
}

// Init AuthClient
initAuthClient();
initAllClients();
}, []);

if (!!ErrorComponent && isInitFailed) {
if (!!ErrorComponent && initState.failed) {
return ErrorComponent;
}

if (!!LoadingComponent && !isInitialized) {
if (!!LoadingComponent && !initState.allInitialized) {
return LoadingComponent;
}

return (
<authContext.Provider
value={{
authClient: enhancedAuthClient,
isAuthenticated,
isInitialized,
}}
>
<multiAuthContext.Provider value={enhancedClientsMap}>
{children}
</authContext.Provider>
</multiAuthContext.Provider>
);
};

/**
* Hook to access a specific authentication client by its ID within the AuthProvider.
* @throws Error if used outside of an AuthProvider
* @throws Error if the provided id is not registered in the clients map
*/
const useAuth = function <K extends keyof M>(id: K): EnhancedAuthClient<M[K], E> {
const ctx = useContext(multiAuthContext);
if (!ctx) {
throw new Error('useAuth hook should be used inside AuthProvider');
}
const client = ctx[id];
if (!client) {
throw new Error(`useAuth: no auth client registered for id "${String(id)}"`);
}
return client;
};

return {
AuthProvider,
authClients: enhancedClientsMap,
useAuth,
};
}

/**
* Creates an authentication context and provider for a React application.
* It wraps the provided `authClient` with enhanced state management and event handling.
*
* This is a convenience wrapper around `createMultiAuth` for the common single-provider case.
* Internally it registers the client under the key `'default'` and re-exports a
* `useAuthClient` hook that delegates to `useAuth('default')`.
*
* @template AC - The type of the base `AuthClient` implementation.
* @template E - The type of error expected during authentication flows. Defaults to `Error`.
* @param {AC} authClient - The base authentication client instance to use.
* @returns An object containing:
* - `AuthProvider`: A React component to wrap the application or parts of it.
* - `authClient`: The enhanced authentication client instance.
* - `useAuthClient`: A hook to access the enhanced `authClient` within the `AuthProvider`.
*/
export function createAuth<AC extends AuthClient, E extends Error = Error>(authClient: AC) {
const { AuthProvider, authClients, useAuth } = createMultiAuth<{ default: AC }, E>({ default: authClient });

/**
* Hook to access the authentication client within the AuthProvider
* @throws Error if used outside of an AuthProvider
*/
const useAuthClient = function (): EnhancedAuthClient<AC, E> {
const ctx = useContext(authContext);
if (!ctx) {
try {
return useAuth('default');
} catch {
throw new Error('useAuthClient hook should be used inside AuthProvider');
}

return ctx.authClient;
};

return {
AuthProvider,
authClient: enhancedAuthClient,
authClient: authClients.default,
useAuthClient,
};
}
4 changes: 2 additions & 2 deletions lib/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { createAuth } from "./auth";
export type { AuthClient } from "./auth";
export { createAuth, createMultiAuth } from "./auth";
export type { AuthClient, EnhancedAuthClient } from "./auth";
Loading
Loading