UNPKG

@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
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 };