@inertiapixel/nextjs-auth
Version:
Authentication system for Next.js. Supports credentials and social login, JWT token management, and lifecycle hooks — designed to integrate with nodejs-auth for full-stack MERN apps.
252 lines (251 loc) • 10.2 kB
JavaScript
// src/context/AuthProvider.tsx
'use client';
import { jsx as _jsx } from "react/jsx-runtime";
import { createContext, useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { parseToken } from '../utils/tokenUtils';
import { loginWithCredentials, loginWithOTP, } from '../utils/auth';
import { SuspenseWrapper } from '../components/SuspenseWrapper';
export const AuthContext = createContext(null);
export const AuthProvider = ({ children, config }) => {
if (!config.apiBaseUrl) {
throw new Error('[nextjs-auth] Missing required "apiBaseUrl". Please set NEXT_PUBLIC_API_BASE_URI in your environment variables and pass it to AuthProvider.');
}
const API_BASE_URL = config.apiBaseUrl;
const loginEndpoint = config.apiEndpoints?.login || '/auth/login';
const logoutEndpoint = config.apiEndpoints?.logout || '/auth/logout';
const refreshEndpoint = config.apiEndpoints?.refresh || '/auth/refresh';
// Memory-based state only
const [token, setToken] = useState(null);
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
const [loginError, setLoginError] = useState(null);
const router = useRouter();
// Try session restore on mount using refresh cookie
useEffect(() => {
const tryRefresh = async () => {
try {
const res = await fetch(`${API_BASE_URL}${refreshEndpoint}`, {
method: 'POST',
credentials: 'include', // important: send cookies
});
// if (!res.ok) throw new Error('Not authenticated');
if (!res.ok) {
setToken(null);
setUser(null);
setIsAuthenticated(false);
const faildRed = {
"provider": "credentials",
"isAuthenticated": false,
"message": "Refresh token is missing"
};
return faildRed;
}
;
const data = await res.json();
if (data.accessToken) {
const decodedUser = parseToken(data.accessToken);
setToken(data.accessToken);
setUser(decodedUser || data.user || null);
setIsAuthenticated(true);
}
}
catch (err) {
console.error("Session refresh failed:", err);
setToken(null);
setUser(null);
setIsAuthenticated(false);
}
finally {
setLoading(false);
}
};
tryRefresh();
}, [API_BASE_URL, refreshEndpoint]);
const handleAuthSuccess = useCallback((response) => {
if (!response.accessToken)
throw new Error("Access token missing in response.");
const decodedUser = parseToken(response.accessToken);
setToken(response.accessToken); // store in memory only
setUser(decodedUser || response.user || null);
setIsAuthenticated(true);
config?.onLoginSuccess?.(decodedUser);
const redirectUrl = localStorage.getItem("redirectTo")
?? config?.redirectTo
?? "/";
router.push(redirectUrl);
}, [config, router]);
const handleAuthFailure = useCallback((error) => {
console.error('handleAuthFailure', error);
setIsAuthenticated(false);
setLoginError(error);
config?.onLoginFail?.(error?.message || 'Login failed');
}, [config]);
const login = useCallback(async (credentials) => {
setLoginError(null);
try {
const response = await handleLoginMethod(credentials);
handleAuthSuccess(response);
}
catch (error) {
handleAuthFailure(error);
}
}, [handleAuthSuccess, handleAuthFailure]);
const handleLoginMethod = async (credentials) => {
const errors = {};
if (credentials.provider === 'credentials') {
if (!credentials.email)
errors.email = 'Email is required.';
if (!credentials.password)
errors.password = 'Password is required.';
if (Object.keys(errors).length > 0) {
throw { type: 'validation', errors };
}
return await loginWithCredentials(`${API_BASE_URL}${loginEndpoint}`, credentials);
}
if (credentials.provider === 'otp') {
if (!credentials.otp)
errors.otp = 'OTP is required.';
if (Object.keys(errors).length > 0) {
throw { type: 'validation', errors };
}
return await loginWithOTP(`${API_BASE_URL}${loginEndpoint}`, credentials);
}
throw {
type: 'invalid_method',
message: 'Invalid login method.',
};
};
const socialLogin = useCallback((provider) => {
return new Promise((resolve, reject) => {
setLoginError(null);
try {
const { popup } = initializeSocialLoginPopup(provider, reject);
setupPopupMonitoring(popup, provider, resolve, reject);
}
catch (error) {
const err = error instanceof Error ? error : new Error('Social login initialization failed');
reject(err);
}
});
}, []);
const initializeSocialLoginPopup = (provider, reject) => {
const { authUrl } = getSocialLoginConfig(provider);
const popup = window.open(authUrl, `${provider}AuthPopup`, 'width=500,height=600,top=100,left=100');
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
const err = new Error('Popup blocked! Please allow popups for this site.');
handleSocialLoginError(err, provider, reject);
throw err;
}
return { authUrl, popup };
};
const getSocialLoginConfig = (provider) => {
const providerConfig = config.socialProviders?.find((p) => p.provider === provider);
if (!providerConfig) {
throw new Error(`Missing social provider config for ${provider}`);
}
const clientId = providerConfig.clientId;
const redirectUriNextJs = `${window.location.origin}/api/auth/${provider}`;
const authUrl = buildOAuthUrl(provider, clientId, redirectUriNextJs);
return { clientId, redirectUriNextJs, authUrl };
};
const buildOAuthUrl = (provider, clientId, redirectUri) => {
const base = {
google: 'https://accounts.google.com/o/oauth2/v2/auth',
facebook: 'https://www.facebook.com/v18.0/dialog/oauth',
linkedin: 'https://www.linkedin.com/oauth/v2/authorization',
}[provider];
const scopeMap = {
google: 'profile email',
facebook: 'email public_profile',
linkedin: 'openid profile email',
};
const state = 'demo_state_123';
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: scopeMap[provider],
...(provider === 'linkedin' && { state }),
...(provider === 'google' && { access_type: 'offline', prompt: 'consent' }),
});
return `${base}?${params.toString()}`;
};
const setupPopupMonitoring = (popup, provider, resolve, reject) => {
if (!popup)
return;
const popupCheckInterval = setInterval(() => {
if (popup.closed) {
clearInterval(popupCheckInterval);
const err = new Error('Authentication was cancelled');
handleSocialLoginError(err, provider, reject);
}
}, 500);
const messageHandler = (event) => {
if (event.origin !== window.location.origin)
return;
const payload = event.data;
window.removeEventListener('message', messageHandler);
clearInterval(popupCheckInterval);
popup?.close();
if (payload.isAuthenticated && payload.accessToken) {
handleAuthSuccess(payload);
resolve();
}
else {
handleAuthFailure(payload);
}
};
window.addEventListener('message', messageHandler);
};
const handleSocialLoginError = (error, provider, reject) => {
setLoginError({
message: error.message,
name: error.name,
provider,
});
config?.onLoginFail?.(error.message);
reject(error);
};
const logout = useCallback(async () => {
try {
await performLogoutRequest();
}
catch (err) {
console.error('Logout failed:', err instanceof Error ? err.message : 'Unknown error');
}
finally {
cleanupAfterLogout();
}
}, [API_BASE_URL, logoutEndpoint, config, router]);
const performLogoutRequest = async () => {
await fetch(`${API_BASE_URL}${logoutEndpoint}`, {
method: 'POST',
credentials: 'include', // important: clears refresh cookie
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
};
const cleanupAfterLogout = () => {
setToken(null);
setUser(null);
setIsAuthenticated(false);
config?.onLogout?.();
const redirectUrl = config?.redirectAfterLogout || config?.redirectTo;
if (redirectUrl)
router.push(redirectUrl);
};
const contextValue = {
user,
isAuthenticated,
loading,
login,
logout,
loginError,
socialLogin,
};
return (_jsx(AuthContext.Provider, { value: contextValue, children: _jsx(SuspenseWrapper, { children: children }) }));
};