import * as React from 'react'; import { createContext, useContext, useCallback, useMemo, useEffect, useState, useRef } from 'react'; import type { User, AuthState, LoginCredentials, RegisterCredentials, OTPVerifyCredentials, MagicLinkVerifyCredentials } from './types'; const TOKEN_STORAGE_KEY = 'auth_token'; const USER_STORAGE_KEY = 'auth_user'; /** * Authentication context value. */ export interface AuthContextValue extends AuthState { /** Log in with email and password */ login: (credentials: LoginCredentials) => Promise; /** Log in with a token directly */ loginWithToken: (token: string, user?: User) => void; /** Register a new account */ register: (credentials: RegisterCredentials) => Promise; /** Log out the current user */ logout: () => Promise; /** Get the current access token */ getToken: () => string | null; /** Check if user has a specific role */ hasRole: (role: string) => boolean; /** Check if user has a specific scope */ hasScope: (scope: string) => boolean; /** Send an OTP code to an email */ sendOTP: (email: string) => Promise; /** Log in with an OTP code */ loginWithOTP: (credentials: OTPVerifyCredentials) => Promise; /** Send a magic link to an email */ sendMagicLink: (email: string) => Promise; /** Log in with a magic link token */ loginWithMagicLink: (credentials: MagicLinkVerifyCredentials) => Promise; /** Refresh the access token */ refreshToken: () => Promise; } const AuthContext = createContext(null); /** * Auth provider configuration. */ export interface AuthProviderProps { children: React.ReactNode; /** API base URL for auth endpoints (e.g. "/api/my-service") */ authBaseUrl?: string; /** API endpoint for login (defaults to authBaseUrl + "/auth/login") */ loginUrl?: string; /** API endpoint for logout */ logoutUrl?: string; /** API endpoint for registration */ registerUrl?: string; /** API endpoint for token refresh */ refreshUrl?: string; /** Custom login handler */ onLogin?: (credentials: LoginCredentials) => Promise<{ token: string; user: User }>; /** Custom logout handler */ onLogout?: () => Promise; /** Storage type for persisting auth state */ storage?: 'localStorage' | 'sessionStorage' | 'none'; } /** * AuthProvider manages authentication state and provides auth methods. */ export function AuthProvider({ children, authBaseUrl, loginUrl, logoutUrl, registerUrl, refreshUrl, onLogin, onLogout, storage = 'localStorage', }: AuthProviderProps) { // Derive URLs from authBaseUrl if individual URLs not provided const resolvedLoginUrl = loginUrl || (authBaseUrl ? `${authBaseUrl}/auth/login` : '/api/auth/login'); const resolvedLogoutUrl = logoutUrl || (authBaseUrl ? `${authBaseUrl}/auth/logout` : '/api/auth/logout'); const resolvedRegisterUrl = registerUrl || (authBaseUrl ? `${authBaseUrl}/auth/register` : '/api/auth/register'); const resolvedRefreshUrl = refreshUrl || (authBaseUrl ? `${authBaseUrl}/auth/refresh` : '/api/auth/refresh'); const resolvedOtpSendUrl = authBaseUrl ? `${authBaseUrl}/auth/otp/send` : '/api/auth/otp/send'; const resolvedOtpVerifyUrl = authBaseUrl ? `${authBaseUrl}/auth/otp/verify` : '/api/auth/otp/verify'; const resolvedMagicLinkUrl = authBaseUrl ? `${authBaseUrl}/auth/magic-link` : '/api/auth/magic-link'; const resolvedMagicLinkVerifyUrl = authBaseUrl ? `${authBaseUrl}/auth/magic-link/verify` : '/api/auth/magic-link/verify'; const [state, setState] = useState({ user: null, isLoading: true, isAuthenticated: false, error: null, }); const refreshTimerRef = useRef | null>(null); // Get storage implementation const getStorage = useCallback(() => { if (storage === 'none') return null; return storage === 'sessionStorage' ? sessionStorage : localStorage; }, [storage]); // Store token and user const persistAuth = useCallback((token: string, user: User) => { const store = getStorage(); if (store) { store.setItem(TOKEN_STORAGE_KEY, token); store.setItem(USER_STORAGE_KEY, JSON.stringify(user)); } }, [getStorage]); // Clear stored auth const clearAuth = useCallback(() => { const store = getStorage(); if (store) { store.removeItem(TOKEN_STORAGE_KEY); store.removeItem(USER_STORAGE_KEY); } }, [getStorage]); // Schedule token refresh (at 80% of token lifetime) const scheduleRefresh = useCallback((token: string) => { if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); } try { const payload = JSON.parse(atob(token.split('.')[1])); const exp = payload.exp * 1000; const iat = payload.iat * 1000; const lifetime = exp - iat; const refreshAt = iat + lifetime * 0.8; const delay = refreshAt - Date.now(); if (delay > 0) { refreshTimerRef.current = setTimeout(async () => { try { const store = getStorage(); const currentToken = store?.getItem(TOKEN_STORAGE_KEY); if (!currentToken) return; const response = await fetch(resolvedRefreshUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${currentToken}`, }, }); if (response.ok) { const data = await response.json(); const newToken = data.data?.token || data.token; const newUser = data.data?.user || data.user; persistAuth(newToken, newUser); setState(s => ({ ...s, user: newUser })); scheduleRefresh(newToken); } else if (response.status === 401) { // Session revoked or token invalid — force logout. clearAuth(); setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); } } catch { // Refresh failed (network error) — clear auth to prevent silent expiry. clearAuth(); setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); } }, delay); } } catch { // Corrupted token in storage — clear it and force logout. clearAuth(); setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); } }, [getStorage, persistAuth, clearAuth, resolvedRefreshUrl]); // Initialize auth state from storage useEffect(() => { const store = getStorage(); if (!store) { setState((s) => ({ ...s, isLoading: false })); return; } const token = store.getItem(TOKEN_STORAGE_KEY); const userJson = store.getItem(USER_STORAGE_KEY); if (token && userJson) { try { const user = JSON.parse(userJson) as User; // Check if the stored token is already expired let tokenExpired = false; let deeplyExpired = false; try { const payload = JSON.parse(atob(token.split('.')[1])); const exp = payload.exp * 1000; tokenExpired = exp < Date.now(); // Session is deeply expired if token expired over 30 days ago deeplyExpired = exp + 30 * 24 * 60 * 60 * 1000 < Date.now(); } catch { // Corrupted token — treat as expired tokenExpired = true; deeplyExpired = true; } if (deeplyExpired) { // Session expired beyond recovery — clear auth store.removeItem(TOKEN_STORAGE_KEY); store.removeItem(USER_STORAGE_KEY); setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); } else if (tokenExpired) { // Token expired but session may still be valid — attempt refresh setState((s) => ({ ...s, isLoading: true })); fetch(resolvedRefreshUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, }) .then((response) => { if (response.ok) { return response.json().then((data) => { const newToken = data.data?.token || data.token; const newUser = data.data?.user || data.user; store.setItem(TOKEN_STORAGE_KEY, newToken); store.setItem(USER_STORAGE_KEY, JSON.stringify(newUser)); setState({ user: newUser, isLoading: false, isAuthenticated: true, error: null }); scheduleRefresh(newToken); }); } // Refresh failed (401, etc.) — clear auth store.removeItem(TOKEN_STORAGE_KEY); store.removeItem(USER_STORAGE_KEY); setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); }) .catch(() => { // Network error during refresh — clear auth store.removeItem(TOKEN_STORAGE_KEY); store.removeItem(USER_STORAGE_KEY); setState({ user: null, isLoading: false, isAuthenticated: false, error: null }); }); } else { // Token is still valid — restore authenticated state setState({ user, isLoading: false, isAuthenticated: true, error: null, }); scheduleRefresh(token); } } catch { store.removeItem(TOKEN_STORAGE_KEY); store.removeItem(USER_STORAGE_KEY); setState((s) => ({ ...s, isLoading: false })); } } else { setState((s) => ({ ...s, isLoading: false })); } }, [getStorage, scheduleRefresh, resolvedRefreshUrl]); // Cleanup timer useEffect(() => { return () => { if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); } }; }, []); // Helper to handle auth response (login, register, OTP verify, magic link verify) const handleAuthResponse = useCallback( (token: string, user: User) => { persistAuth(token, user); setState({ user, isLoading: false, isAuthenticated: true, error: null, }); scheduleRefresh(token); }, [persistAuth, scheduleRefresh] ); // Login with credentials const login = useCallback( async (credentials: LoginCredentials) => { setState((s) => ({ ...s, isLoading: true, error: null })); try { let token: string; let user: User; if (onLogin) { const result = await onLogin(credentials); token = result.token; user = result.user; } else { const response = await fetch(resolvedLoginUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); if (!response.ok) { const errBody = await response.json().catch(() => ({})); const errMsg = errBody.error?.message || errBody.message || 'Login failed'; throw new Error(errMsg); } const data = await response.json(); token = data.data?.token || data.token; user = data.data?.user || data.user; } handleAuthResponse(token, user); } catch (error) { setState({ user: null, isLoading: false, isAuthenticated: false, error: error instanceof Error ? error : new Error('Login failed'), }); throw error; } }, [resolvedLoginUrl, onLogin, handleAuthResponse] ); // Login with token directly const loginWithToken = useCallback( (token: string, user?: User) => { const store = getStorage(); if (store) { store.setItem(TOKEN_STORAGE_KEY, token); if (user) { store.setItem(USER_STORAGE_KEY, JSON.stringify(user)); } } setState({ user: user || null, isLoading: false, isAuthenticated: true, error: null, }); scheduleRefresh(token); }, [getStorage, scheduleRefresh] ); // Register const register = useCallback( async (credentials: RegisterCredentials) => { setState((s) => ({ ...s, isLoading: true, error: null })); try { const response = await fetch(resolvedRegisterUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); if (!response.ok) { const errBody = await response.json().catch(() => ({})); const errMsg = errBody.error?.message || errBody.message || 'Registration failed'; throw new Error(errMsg); } const data = await response.json(); const token = data.data?.token || data.token; const user = data.data?.user || data.user; handleAuthResponse(token, user); } catch (error) { setState({ user: null, isLoading: false, isAuthenticated: false, error: error instanceof Error ? error : new Error('Registration failed'), }); throw error; } }, [resolvedRegisterUrl, handleAuthResponse] ); // Logout const logout = useCallback(async () => { if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); } try { if (onLogout) { await onLogout(); } else { const store = getStorage(); const token = store?.getItem(TOKEN_STORAGE_KEY); if (token) { await fetch(resolvedLogoutUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, }).catch(() => {}); } } } finally { clearAuth(); setState({ user: null, isLoading: false, isAuthenticated: false, error: null, }); } }, [resolvedLogoutUrl, onLogout, getStorage, clearAuth]); // Send OTP const sendOTP = useCallback( async (email: string) => { const response = await fetch(resolvedOtpSendUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); if (!response.ok) { const errBody = await response.json().catch(() => ({})); throw new Error(errBody.error?.message || errBody.message || 'Failed to send code'); } }, [resolvedOtpSendUrl] ); // Login with OTP const loginWithOTP = useCallback( async (credentials: OTPVerifyCredentials) => { setState((s) => ({ ...s, isLoading: true, error: null })); try { const response = await fetch(resolvedOtpVerifyUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); if (!response.ok) { const errBody = await response.json().catch(() => ({})); throw new Error(errBody.error?.message || errBody.message || 'Invalid code'); } const data = await response.json(); const token = data.data?.token || data.token; const user = data.data?.user || data.user; handleAuthResponse(token, user); } catch (error) { setState((s) => ({ ...s, isLoading: false, error: error instanceof Error ? error : new Error('OTP verification failed'), })); throw error; } }, [resolvedOtpVerifyUrl, handleAuthResponse] ); // Send magic link const sendMagicLink = useCallback( async (email: string) => { const response = await fetch(resolvedMagicLinkUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }); if (!response.ok) { const errBody = await response.json().catch(() => ({})); throw new Error(errBody.error?.message || errBody.message || 'Failed to send link'); } }, [resolvedMagicLinkUrl] ); // Login with magic link token const loginWithMagicLink = useCallback( async (credentials: MagicLinkVerifyCredentials) => { setState((s) => ({ ...s, isLoading: true, error: null })); try { const response = await fetch(resolvedMagicLinkVerifyUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials), }); if (!response.ok) { const errBody = await response.json().catch(() => ({})); throw new Error(errBody.error?.message || errBody.message || 'Invalid link'); } const data = await response.json(); const token = data.data?.token || data.token; const user = data.data?.user || data.user; handleAuthResponse(token, user); } catch (error) { setState((s) => ({ ...s, isLoading: false, error: error instanceof Error ? error : new Error('Magic link verification failed'), })); throw error; } }, [resolvedMagicLinkVerifyUrl, handleAuthResponse] ); // Refresh token const refreshTokenFn = useCallback(async () => { const store = getStorage(); const currentToken = store?.getItem(TOKEN_STORAGE_KEY); if (!currentToken) return; const response = await fetch(resolvedRefreshUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${currentToken}`, }, }); if (!response.ok) { throw new Error('Token refresh failed'); } const data = await response.json(); const newToken = data.data?.token || data.token; const newUser = data.data?.user || data.user; persistAuth(newToken, newUser); setState(s => ({ ...s, user: newUser })); scheduleRefresh(newToken); }, [getStorage, resolvedRefreshUrl, persistAuth, scheduleRefresh]); // Get token const getToken = useCallback(() => { const store = getStorage(); return store ? store.getItem(TOKEN_STORAGE_KEY) : null; }, [getStorage]); // Role check const hasRole = useCallback( (role: string) => { return state.user?.roles?.includes(role) ?? false; }, [state.user] ); // Scope check const hasScope = useCallback( (scope: string) => { return state.user?.scopes?.includes(scope) ?? false; }, [state.user] ); const value = useMemo( (): AuthContextValue => ({ ...state, login, loginWithToken, register, logout, getToken, hasRole, hasScope, sendOTP, loginWithOTP, sendMagicLink, loginWithMagicLink, refreshToken: refreshTokenFn, }), [state, login, loginWithToken, register, logout, getToken, hasRole, hasScope, sendOTP, loginWithOTP, sendMagicLink, loginWithMagicLink, refreshTokenFn] ); return {children}; } /** * Hook to access authentication state and methods. * * @example * function Profile() { * const { user, logout, isAuthenticated } = useAuth(); * * if (!isAuthenticated) { * return ; * } * * return ( *
*

Welcome, {user?.name}

* *
* ); * } */ export function useAuth(): AuthContextValue { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within an AuthProvider'); } return context; }