persona-community-5/packages/auth/src/AuthProvider.tsx
jordan bd2f591b98
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-24 07:39:46 +00:00

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