@wristband/react-client-auth
Version:
A lightweight React SDK that pairs with your backend server auth to initialize and sync frontend sessions via secure session cookies.
275 lines (272 loc) • 11.7 kB
JavaScript
import { jsx } from 'react/jsx-runtime';
import { useState, useRef, useMemo, useCallback, useEffect } from 'react';
import { WristbandAuthContext } from './wristband-auth-context.js';
import { AuthStatus } from '../types/auth-provider-types.js';
import apiClient from '../api/api-client.js';
import { validateAuthProviderLogoutUrl, validateAuthProviderSessionUrl, validateAuthProviderTokenUrl, resolveAuthProviderLoginUrl, isUnauthorizedError, is4xxError, delay } from '../utils/auth-provider-utils.js';
import { WristbandTokenError } from '../error/index.js';
const TOKEN_EXPIRATION_BUFFER_TIME_MS = 30000;
const MAX_API_ATTEMPTS = 3;
const API_RETRY_DELAY_MS = 100;
/**
* WristbandAuthProvider establishes an authenticated session with your backend server
* by making a request to your session endpoint. It manages authentication state and
* provides session data to all child components through React Context.
*
* This component should be placed near the root of your application to make authentication
* state and session data available throughout your component tree.
*
* @example Basic usage
* ```jsx
* function App() {
* return (
* <WristbandAuthProvider
* loginUrl="/api/auth/login"
* logoutUrl="/api/auth/logout"
* sessionUrl="/api/auth/session"
* >
* <YourAppComponents />
* </WristbandAuthProvider>
* );
* }
* ```
*
* * @example Using access tokens directly in React
* ```jsx
* function App() {
* return (
* <WristbandAuthProvider
* loginUrl="/api/auth/login"
* logoutUrl="/api/auth/logout"
* sessionUrl="/api/auth/session"
* tokenUrl="/api/auth/token"
* >
* <YourAppComponents />
* </WristbandAuthProvider>
* );
* }
* ```
*
* @example With custom session metadata handling
* ```jsx
* function App() {
* const queryClient = useQueryClient();
*
* return (
* <WristbandAuthProvider
* loginUrl="/api/auth/login"
* logoutUrl="/api/auth/logout"
* sessionUrl="/api/auth/session"
* transformSessionMetadata={(rawMetadata) => ({
* name: rawMetadata.displayName,
* email: rawMetadata.email,
* role: rawMetadata.userRole
* })}
* onSessionSuccess={(sessionData) => {
* // Cache additional data in React Query
* queryClient.setQueryData(['user-permissions'], sessionData.permissions);
* }}
* >
* <YourAppComponents />
* </WristbandAuthProvider>
* );
* }
* ```
*
* Once rendered, child components can access authentication state using the hooks:
* - useWristbandAuth() - For authentication status (isAuthenticated, isLoading, authStatus, clearAuthData)
* - useWristbandSession() - For session data (userId, tenantId, metadata)
* - useWristbandToken() - For client-side token management, if applicable (getToken, clearToken)
*
* @template TSessionMetaData - Type for the transformed session metadata, if applicable.
*/
function WristbandAuthProvider({ children, csrfCookieName = 'CSRF-TOKEN', csrfHeaderName = 'X-CSRF-TOKEN', disableRedirectOnUnauthenticated = false, loginUrl, logoutUrl, onSessionSuccess, sessionUrl, tokenUrl = '', transformSessionMetadata, }) {
// Internal State
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [userId, setUserId] = useState('');
const [tenantId, setTenantId] = useState('');
const [metadata, setMetadata] = useState({});
const [accessToken, setAccessToken] = useState('');
const [accessTokenExpiresAt, setAccessTokenExpiresAt] = useState(0);
// Tracks in-flight token requests to prevent duplicate API calls.
const tokenRequestRef = useRef(null);
// Convenience enum for users who don't want to check both isAuthenticated and isLoading
const authStatus = isLoading
? AuthStatus.LOADING
: isAuthenticated
? AuthStatus.AUTHENTICATED
: AuthStatus.UNAUTHENTICATED;
const { resolvedLoginUrl, validatedLogoutUrl, validatedSessionUrl, validatedTokenUrl } = useMemo(() => {
// All validations happen first before any useEffect
validateAuthProviderLogoutUrl(logoutUrl);
validateAuthProviderSessionUrl(sessionUrl);
if (tokenUrl) {
validateAuthProviderTokenUrl(tokenUrl);
}
return {
resolvedLoginUrl: resolveAuthProviderLoginUrl(loginUrl),
validatedLogoutUrl: logoutUrl,
validatedSessionUrl: sessionUrl,
validatedTokenUrl: tokenUrl,
};
}, [loginUrl, logoutUrl, sessionUrl, tokenUrl]);
/**
* Destroys all auth, session, and token data.
*/
const clearAuthData = useCallback(() => {
clearToken();
setIsAuthenticated(false);
setIsLoading(false);
setTenantId('');
setUserId('');
setMetadata({});
}, []);
/**
* Allows setting of session metadata even after initial fetchSession() occurs.
*/
const updateMetadata = useCallback((newMetadata) => {
setMetadata((prevData) => ({ ...prevData, ...newMetadata }));
}, []);
/**
* Clear token cache and any in-flight token request.
*/
const clearToken = useCallback(() => {
setAccessToken('');
setAccessTokenExpiresAt(0);
tokenRequestRef.current = null;
}, []);
/**
* Token management with deduplication and caching. Server handles token refresh using session
* cookie, so client only needs to cache and deduplicate.
*/
const getToken = useCallback(async () => {
if (!validatedTokenUrl || !validatedTokenUrl.trim()) {
throw new WristbandTokenError('TOKEN_URL_NOT_CONFIGURED', 'Token URL not configured');
}
if (!isAuthenticated) {
throw new WristbandTokenError('UNAUTHENTICATED', 'User is not authenticated');
}
// Check if we have a valid cached token (with 30 second buffer)
if (accessToken && accessTokenExpiresAt > Date.now() + TOKEN_EXPIRATION_BUFFER_TIME_MS) {
return accessToken;
}
// If there's already a token request in flight, return that promise
if (tokenRequestRef.current) {
return tokenRequestRef.current;
}
// Create new token request; server will handle refresh using session cookie
const tokenRequest = (async () => {
let lastError;
try {
for (let attempt = 1; attempt <= MAX_API_ATTEMPTS; attempt++) {
try {
const response = await apiClient.get(validatedTokenUrl, { csrfCookieName, csrfHeaderName });
const { accessToken: newToken, expiresAt } = response.data;
setAccessToken(newToken);
setAccessTokenExpiresAt(expiresAt);
return newToken;
}
catch (error) {
lastError = error;
// If token fetch fails due to auth error, clear token state.
if (isUnauthorizedError(error)) {
setAccessToken('');
setAccessTokenExpiresAt(0);
throw new WristbandTokenError('UNAUTHENTICATED', 'Token request unauthorized', error);
}
// If it's any other 4xx error, bail early (don't retry client errors).
if (is4xxError(error)) {
throw new WristbandTokenError('TOKEN_FETCH_FAILED', 'Failed to fetch token', error);
}
// If this is the last attempt, throw the last error
if (attempt === MAX_API_ATTEMPTS) {
break;
}
// Wait before retrying (only for 5xx errors and network issues)
await delay(API_RETRY_DELAY_MS);
}
}
// All attempts failed, so throw an error
throw new WristbandTokenError('TOKEN_FETCH_FAILED', 'Failed to fetch token', lastError);
}
finally {
// Clear the in-flight request
tokenRequestRef.current = null;
}
})();
// Store the promise to prevent duplicate requests
tokenRequestRef.current = tokenRequest;
return tokenRequest;
}, [isAuthenticated, accessToken, accessTokenExpiresAt]);
/**
* Bootstrap the application with the authenticated user's session data via session cookie.
*/
useEffect(() => {
const fetchSession = async () => {
let lastError;
for (let attempt = 1; attempt <= MAX_API_ATTEMPTS; attempt++) {
try {
// The session API will let React know if the user has a previously authenticated session.
// If so, it will initialize session data.
const response = await apiClient.get(validatedSessionUrl, {
csrfCookieName,
csrfHeaderName,
});
const { userId, tenantId, metadata: rawMetadata } = response.data;
// Execute side effects callback before updating state if provided
if (onSessionSuccess) {
await Promise.resolve(onSessionSuccess(response.data));
}
// Apply transformation if provided
if (rawMetadata) {
setMetadata(transformSessionMetadata ? transformSessionMetadata(rawMetadata) : rawMetadata);
}
// Update remaining context state last
setTenantId(tenantId || '');
setUserId(userId || '');
setIsAuthenticated(true);
setIsLoading(false);
return;
}
catch (error) {
lastError = error;
// If it's a 4xx error, bail early (don't retry client errors)
if (is4xxError(error)) {
break; // Exit retry loop for client errors
}
// If this is the last attempt, don't delay
if (attempt === MAX_API_ATTEMPTS) {
break;
}
// Wait before retrying (only for 5xx errors and network issues)
await delay(API_RETRY_DELAY_MS);
}
}
console.log(lastError);
if (disableRedirectOnUnauthenticated) {
setIsAuthenticated(false);
setIsLoading(false);
}
else {
// Don't call logout on 401 to preserve the current page for when the user returns after re-authentication.
window.location.href = isUnauthorizedError(lastError) ? resolvedLoginUrl : validatedLogoutUrl;
}
};
fetchSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (jsx(WristbandAuthContext.Provider, { value: {
authStatus,
clearAuthData,
clearToken,
getToken,
isAuthenticated,
isLoading,
metadata,
tenantId,
updateMetadata,
userId,
}, children: children }));
}
export { WristbandAuthProvider };