615 lines
19 KiB
TypeScript
615 lines
19 KiB
TypeScript
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<void>;
|
|
/** Log in with a token directly */
|
|
loginWithToken: (token: string, user?: User) => void;
|
|
/** Register a new account */
|
|
register: (credentials: RegisterCredentials) => Promise<void>;
|
|
/** Log out the current user */
|
|
logout: () => Promise<void>;
|
|
/** 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<void>;
|
|
/** Log in with an OTP code */
|
|
loginWithOTP: (credentials: OTPVerifyCredentials) => Promise<void>;
|
|
/** Send a magic link to an email */
|
|
sendMagicLink: (email: string) => Promise<void>;
|
|
/** Log in with a magic link token */
|
|
loginWithMagicLink: (credentials: MagicLinkVerifyCredentials) => Promise<void>;
|
|
/** Refresh the access token */
|
|
refreshToken: () => Promise<void>;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(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<void>;
|
|
/** 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<AuthState>({
|
|
user: null,
|
|
isLoading: true,
|
|
isAuthenticated: false,
|
|
error: null,
|
|
});
|
|
|
|
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | 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 <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
/**
|
|
* Hook to access authentication state and methods.
|
|
*
|
|
* @example
|
|
* function Profile() {
|
|
* const { user, logout, isAuthenticated } = useAuth();
|
|
*
|
|
* if (!isAuthenticated) {
|
|
* return <LoginForm />;
|
|
* }
|
|
*
|
|
* return (
|
|
* <div>
|
|
* <p>Welcome, {user?.name}</p>
|
|
* <button onClick={logout}>Logout</button>
|
|
* </div>
|
|
* );
|
|
* }
|
|
*/
|
|
export function useAuth(): AuthContextValue {
|
|
const context = useContext(AuthContext);
|
|
if (!context) {
|
|
throw new Error('useAuth must be used within an AuthProvider');
|
|
}
|
|
return context;
|
|
}
|