In previous modules, we learned the basics of authentication, connecting external providers, session management, the role and permission system, and middleware for route protection. Now we will deal with creating a Context Provider for authentication that will facilitate access to authentication data and functions throughout the React application.
React's Context API allows passing data through the component tree without the need to pass props at every level. This is an ideal solution for functionality that is needed in many places of the application - exactly like authentication.
NextAuth.js already provides
SessionProvider, which provides basic session information. However, it is often worth extending this functionality with additional capabilities:Let's start by defining the basic authentication context:
1// lib/authContext.tsx
2import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
3import { Session } from 'next-auth';
4import { useSession, signIn, signOut } from 'next-auth/react';
5
6// Defining the type for the authentication context
7type AuthContextType = {
8 session: Session | null;
9 status: 'loading' | 'authenticated' | 'unauthenticated';
10 isLoading: boolean;
11 isAuthenticated: boolean;
12 user: Session['user'] | null;
13 hasRoles: (role: string | string[]) => boolean;
14 hasPermission: (permission: string | string[]) => boolean;
15 signIn: (provider?: string, options?: any) => Promise<any>;
16 signOut: (options?: any) => Promise<any>;
17 error: string | null;
18};
19
20// Creating context with default value (null)
21const AuthContext = createContext<AuthContextType | null>(null);
22
23// Hook that allows easy access to the context
24export function useAuth() {
25 const context = useContext(AuthContext);
26
27 if (!context) {
28 throw new Error('useAuth must be used within an AuthProvider');
29 }
30
31 return context;
32}
33
34// Provider component
35interface AuthProviderProps {
36 children: ReactNode;
37}
38
39export function AuthProvider({ children }: AuthProviderProps) {
40 // We use useSession from NextAuth.js to fetch session data
41 const { data: session, status } = useSession();
42 const [error, setError] = useState<string | null>(null);
43
44 // Check if the user has a specific role
45 const hasRoles = (role: string | string[]): boolean => {
46 if (!session?.user?.role) return false;
47
48 if (Array.isArray(role)) {
49 return role.includes(session.user.role as string);
50 }
51
52 return session.user.role === role;
53 };
54
55 // Check if the user has a specific permission
56 const hasPermission = (permission: string | string[]): boolean => {
57 if (!session?.user?.permissions) return false;
58
59 const userPermissions = session.user.permissions as string[];
60
61 if (Array.isArray(permission)) {
62 return permission.some(p => userPermissions.includes(p));
63 }
64
65 return userPermissions.includes(permission as string);
66 };
67
68 // Simple function wrapping signIn with error handling
69 const handleSignIn = async (provider?: string, options?: any) => {
70 try {
71 setError(null);
72 return await signIn(provider, options);
73 } catch (err) {
74 console.error('Login error:', err);
75 setError('A problem occurred during login. Please try again.');
76 throw err;
77 }
78 };
79
80 // Simple function wrapping signOut
81 const handleSignOut = async (options?: any) => {
82 try {
83 return await signOut(options);
84 } catch (err) {
85 console.error('Logout error:', err);
86 setError('A problem occurred during logout.');
87 throw err;
88 }
89 };
90
91 // Context value that will be shared
92 const value: AuthContextType = {
93 session,
94 status,
95 isLoading: status === 'loading',
96 isAuthenticated: status === 'authenticated',
97 user: session?.user || null,
98 hasRoles,
99 hasPermission,
100 signIn: handleSignIn,
101 signOut: handleSignOut,
102 error,
103 };
104
105 return (
106 <AuthContext.Provider value={value}>
107 {children}
108 </AuthContext.Provider>
109 );
110}Authorization is an application-wide functionality, so it is best to add AuthProvider at a high level in the component tree, preferably in the main application layout, wrapping
SessionProvider from NextAuth.js:1// app/providers.tsx
2'use client';
3
4import { SessionProvider } from 'next-auth/react';
5import { AuthProvider } from '@/lib/authContext';
6import { ReactNode } from 'react';
7
8export function Providers({ children }: { children: ReactNode }) {
9 return (
10 <SessionProvider>
11 <AuthProvider>
12 {children}
13 </AuthProvider>
14 </SessionProvider>
15 );
16}
17
18// app/layout.tsx
19import { Providers } from "./providers";
20
21export default function RootLayout({
22 children,
23}: {
24 children: React.ReactNode;
25}) {
26 return (
27 <html lang="pl">
28 <body>
29 <Providers>
30 {children}
31 </Providers>
32 </body>
33 </html>
34 );
35}We often also need specialized contexts for specific authentication functionalities, such as password reset. Here is an example of such a provider:
1// lib/passwordResetContext.tsx
2import { createContext, useContext, useState, ReactNode } from 'react';
3
4type PasswordResetContextType = {
5 isLoading: boolean;
6 error: string | null;
7 message: string | null;
8 requestPasswordReset: (email: string) => Promise<boolean>;
9 resetPassword: (token: string, newPassword: string) => Promise<boolean>;
10};
11
12const PasswordResetContext = createContext<PasswordResetContextType | null>(null);
13
14export function usePasswordReset() {
15 const context = useContext(PasswordResetContext);
16
17 if (!context) {
18 throw new Error('usePasswordReset must be used within a PasswordResetProvider');
19 }
20
21 return context;
22}
23
24interface PasswordResetProviderProps {
25 children: ReactNode;
26}
27
28export function PasswordResetProvider({ children }: PasswordResetProviderProps) {
29 const [isLoading, setIsLoading] = useState(false);
30 const [error, setError] = useState<string | null>(null);
31 const [message, setMessage] = useState<string | null>(null);
32
33 // Function to send password reset request
34 const requestPasswordReset = async (email: string): Promise<boolean> => {
35 setIsLoading(true);
36 setError(null);
37 setMessage(null);
38
39 try {
40 const response = await fetch('/api/auth/reset-password', {
41 method: 'POST',
42 headers: { 'Content-Type': 'application/json' },
43 body: JSON.stringify({ email }),
44 });
45
46 const data = await response.json();
47
48 if (!response.ok) {
49 throw new Error(data.message || 'A problem occurred while sending the reset link');
50 }
51
52 setMessage('Password reset instructions have been sent to the provided email address.');
53 return true;
54 } catch (err) {
55 setError(err instanceof Error ? err.message : 'An unknown error occurred');
56 return false;
57 } finally {
58 setIsLoading(false);
59 }
60 };
61
62 // Function to set a new password
63 const resetPassword = async (token: string, newPassword: string): Promise<boolean> => {
64 setIsLoading(true);
65 setError(null);
66 setMessage(null);
67
68 try {
69 const response = await fetch('/api/auth/reset-password/confirm', {
70 method: 'POST',
71 headers: { 'Content-Type': 'application/json' },
72 body: JSON.stringify({ token, newPassword }),
73 });
74
75 const data = await response.json();
76
77 if (!response.ok) {
78 throw new Error(data.message || 'A problem occurred while resetting the password');
79 }
80
81 setMessage('Password has been successfully changed. You can now log in.');
82 return true;
83 } catch (err) {
84 setError(err instanceof Error ? err.message : 'An unknown error occurred');
85 return false;
86 } finally {
87 setIsLoading(false);
88 }
89 };
90
91 const value: PasswordResetContextType = {
92 isLoading,
93 error,
94 message,
95 requestPasswordReset,
96 resetPassword,
97 };
98
99 return (
100 <PasswordResetContext.Provider value={value}>
101 {children}
102 </PasswordResetContext.Provider>
103 );
104}We can extend our AuthProvider with additional features that will often be needed in applications:
1// Extended methods in AuthContext
2type AuthContextType = {
3 // ... existing fields
4 registerUser: (userData: RegisterUserData) => Promise<boolean>;
5};
6
7// Registration data
8type RegisterUserData = {
9 name: string;
10 email: string;
11 password: string;
12};
13
14// Implementation in AuthProvider
15export function AuthProvider({ children }: AuthProviderProps) {
16 // ... existing code
17
18 const registerUser = async (userData: RegisterUserData): Promise<boolean> => {
19 try {
20 setError(null);
21 const response = await fetch('/api/auth/register', {
22 method: 'POST',
23 headers: { 'Content-Type': 'application/json' },
24 body: JSON.stringify(userData),
25 });
26
27 const data = await response.json();
28
29 if (!response.ok) {
30 throw new Error(data.message || 'A problem occurred during registration');
31 }
32
33 return true;
34 } catch (err) {
35 console.error('Registration error:', err);
36 setError(err instanceof Error ? err.message : 'An unknown error occurred during registration');
37 return false;
38 }
39 };
40
41 const value: AuthContextType = {
42 // ... existing fields
43 registerUser,
44 };
45
46 // ... existing code
47}1// Extended methods in AuthContext
2type AuthContextType = {
3 // ... existing fields
4 updateUserProfile: (profileData: UpdateProfileData) => Promise<boolean>;
5};
6
7// Profile update data
8type UpdateProfileData = {
9 name?: string;
10 email?: string;
11 currentPassword?: string;
12 newPassword?: string;
13 image?: string;
14};
15
16// Implementation in AuthProvider
17export function AuthProvider({ children }: AuthProviderProps) {
18 // ... existing code
19
20 const updateUserProfile = async (profileData: UpdateProfileData): Promise<boolean> => {
21 try {
22 setError(null);
23 const response = await fetch('/api/user/profile', {
24 method: 'PUT',
25 headers: { 'Content-Type': 'application/json' },
26 body: JSON.stringify(profileData),
27 });
28
29 const data = await response.json();
30
31 if (!response.ok) {
32 throw new Error(data.message || 'A problem occurred while updating the profile');
33 }
34
35 // Force session refresh to fetch updated data
36 await refreshSession();
37
38 return true;
39 } catch (err) {
40 console.error('Profile update error:', err);
41 setError(err instanceof Error ? err.message : 'An unknown error occurred during profile update');
42 return false;
43 }
44 };
45
46 // Function that refreshes the session
47 const refreshSession = async () => {
48 // Currently in Next.js 13+ with App Router we can use:
49 const event = new Event('visibilitychange');
50 document.dispatchEvent(event);
51
52 // Alternatively, we can implement session refresh ourselves
53 // e.g. by re-rendering the provider
54 };
55
56 const value: AuthContextType = {
57 // ... existing fields
58 updateUserProfile,
59 };
60
61 // ... existing code
62}1// Extended methods in AuthContext
2type AuthContextType = {
3 // ... existing fields
4 enable2FA: () => Promise<{ secret: string; qrCodeUrl: string }>;
5 verify2FA: (token: string) => Promise<boolean>;
6 disable2FA: (token: string) => Promise<boolean>;
7 is2FAEnabled: boolean;
8};
9
10// Implementation in AuthProvider
11export function AuthProvider({ children }: AuthProviderProps) {
12 // ... existing code
13 const [is2FAEnabled, setIs2FAEnabled] = useState(false);
14
15 // Update the 2FA state based on the session
16 useEffect(() => {
17 if (session?.user?.twoFactorEnabled) {
18 setIs2FAEnabled(true);
19 } else {
20 setIs2FAEnabled(false);
21 }
22 }, [session]);
23
24 // Enable 2FA
25 const enable2FA = async () => {
26 try {
27 setError(null);
28 const response = await fetch('/api/auth/2fa/enable', {
29 method: 'POST',
30 });
31
32 if (!response.ok) {
33 const data = await response.json();
34 throw new Error(data.message || 'A problem occurred while enabling 2FA');
35 }
36
37 const { secret, qrCodeUrl } = await response.json();
38 return { secret, qrCodeUrl };
39 } catch (err) {
40 console.error('Enable 2FA error:', err);
41 setError(err instanceof Error ? err.message : 'An unknown error occurred');
42 throw err;
43 }
44 };
45
46 // Verify the 2FA token
47 const verify2FA = async (token: string) => {
48 try {
49 setError(null);
50 const response = await fetch('/api/auth/2fa/verify', {
51 method: 'POST',
52 headers: { 'Content-Type': 'application/json' },
53 body: JSON.stringify({ token }),
54 });
55
56 if (!response.ok) {
57 const data = await response.json();
58 throw new Error(data.message || 'Invalid verification code');
59 }
60
61 // Refresh the session to update the 2FA state
62 await refreshSession();
63 setIs2FAEnabled(true);
64
65 return true;
66 } catch (err) {
67 console.error('Verify 2FA error:', err);
68 setError(err instanceof Error ? err.message : 'An unknown error occurred');
69 return false;
70 }
71 };
72
73 // Disable 2FA
74 const disable2FA = async (token: string) => {
75 try {
76 setError(null);
77 const response = await fetch('/api/auth/2fa/disable', {
78 method: 'POST',
79 headers: { 'Content-Type': 'application/json' },
80 body: JSON.stringify({ token }),
81 });
82
83 if (!response.ok) {
84 const data = await response.json();
85 throw new Error(data.message || 'Invalid verification code');
86 }
87
88 // Refresh the session to update the 2FA state
89 await refreshSession();
90 setIs2FAEnabled(false);
91
92 return true;
93 } catch (err) {
94 console.error('Disable 2FA error:', err);
95 setError(err instanceof Error ? err.message : 'An unknown error occurred');
96 return false;
97 }
98 };
99
100 const value: AuthContextType = {
101 // ... existing fields
102 enable2FA,
103 verify2FA,
104 disable2FA,
105 is2FAEnabled,
106 };
107
108 // ... existing code
109}Now we can use our AuthContext in application components:
1// components/AuthButton.tsx
2'use client';
3
4import { useAuth } from '@/lib/authContext';
5
6export function AuthButton() {
7 const { isAuthenticated, isLoading, signIn, signOut, user } = useAuth();
8
9 if (isLoading) {
10 return <button className="btn btn-ghost animate-pulse">Loading...</button>;
11 }
12
13 if (isAuthenticated) {
14 return (
15 <button
16 onClick={() => signOut({ callbackUrl: '/' })}
17 className="btn btn-outline"
18 >
19 Log out {user?.name}
20 </button>
21 );
22 }
23
24 return (
25 <button
26 onClick={() => signIn()}
27 className="btn btn-primary"
28 >
29 Log in
30 </button>
31 );
32}1// app/dashboard/page.tsx
2'use client';
3
4import { useAuth } from '@/lib/authContext';
5import { useEffect } from 'react';
6import { useRouter } from 'next/navigation';
7
8export default function DashboardPage() {
9 const { isAuthenticated, isLoading, user, hasRoles } = useAuth();
10 const router = useRouter();
11
12 // Redirect to the login page if the user is not logged in
13 useEffect(() => {
14 if (!isLoading && !isAuthenticated) {
15 router.push('/auth/login?callbackUrl=/dashboard');
16 }
17 }, [isLoading, isAuthenticated, router]);
18
19 if (isLoading) {
20 return <div className="flex justify-center p-8">Loading...</div>;
21 }
22
23 if (!isAuthenticated) {
24 return null; // Avoid flicker before redirecting
25 }
26
27 return (
28 <div className="container mx-auto p-6">
29 <h1 className="text-2xl font-bold mb-6">Hello, {user?.name}!</h1>
30
31 <div className="bg-white shadow rounded-lg p-6">
32 <h2 className="text-xl font-semibold mb-4">Dashboard</h2>
33
34 {/* Content available to all logged-in users */}
35 <div className="mb-6">
36 <h3 className="text-lg font-medium mb-2">Your profile</h3>
37 <p>Email: {user?.email}</p>
38 <p>Role: {user?.role}</p>
39 </div>
40
41 {/* Content visible only to administrators */}
42 {hasRoles('admin') && (
43 <div className="p-4 bg-purple-50 rounded-lg">
44 <h3 className="text-lg font-medium mb-2 text-purple-800">Panel administratora</h3>
45 <p className="text-purple-700">This section is visible only to administrators.</p>
46 <button className="mt-2 px-4 py-2 bg-purple-600 text-white rounded">Manage users</button>
47 </div>
48 )}
49
50 {/* Content visible only to specific roles */}
51 {hasRoles(['admin', 'editor']) && (
52 <div className="mt-4 p-4 bg-blue-50 rounded-lg">
53 <h3 className="text-lg font-medium mb-2 text-blue-800">Content management</h3>
54 <p className="text-blue-700">This section is available to administrators and editors.</p>
55 </div>
56 )}
57 </div>
58 </div>
59 );
60}1// app/auth/register/page.tsx
2'use client';
3
4import { useAuth } from '@/lib/authContext';
5import { useState, FormEvent } from 'react';
6import { useRouter } from 'next/navigation';
7import Link from 'next/link';
8
9export default function RegisterPage() {
10 const { registerUser, error: authError, isAuthenticated } = useAuth();
11 const router = useRouter();
12
13 const [name, setName] = useState('');
14 const [email, setEmail] = useState('');
15 const [password, setPassword] = useState('');
16 const [confirmPassword, setConfirmPassword] = useState('');
17 const [isLoading, setIsLoading] = useState(false);
18 const [formError, setFormError] = useState<string | null>(null);
19 const [successMessage, setSuccessMessage] = useState<string | null>(null);
20
21 // If the user is already logged in, redirect to the dashboard
22 if (isAuthenticated) {
23 router.push('/dashboard');
24 return null;
25 }
26
27 const handleSubmit = async (e: FormEvent) => {
28 e.preventDefault();
29 setFormError(null);
30 setSuccessMessage(null);
31
32 // Validate form
33 if (password !== confirmPassword) {
34 setFormError('Passwords do not match');
35 return;
36 }
37
38 if (password.length < 8) {
39 setFormError('Password must be at least 8 characters');
40 return;
41 }
42
43 setIsLoading(true);
44
45 try {
46 const success = await registerUser({ name, email, password });
47
48 if (success) {
49 setSuccessMessage('Registration completed successfully! You can now log in.');
50 // Optionally: redirect to the login page after a short delay
51 setTimeout(() => {
52 router.push('/auth/login');
53 }, 2000);
54 }
55 } catch (err) {
56 console.error('Registration error:', err);
57 // Error is already handled by AuthProvider
58 } finally {
59 setIsLoading(false);
60 }
61 };
62
63 return (
64 <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
65 <h1 className="text-2xl font-bold mb-6 text-center">Registration</h1>
66
67 {/* Display errors */}
68 {(formError || authError) && (
69 <div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">
70 {formError || authError}
71 </div>
72 )}
73
74 {/* Display success message */}
75 {successMessage && (
76 <div className="mb-4 p-3 bg-green-100 text-green-700 rounded-md">
77 {successMessage}
78 </div>
79 )}
80
81 <form onSubmit={handleSubmit} className="space-y-4">
82 <div>
83 <label htmlFor="name" className="block text-sm font-medium mb-1">
84 Full name
85 </label>
86 <input
87 id="name"
88 type="text"
89 value={name}
90 onChange={(e) => setName(e.target.value)}
91 className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
92 required
93 />
94 </div>
95
96 <div>
97 <label htmlFor="email" className="block text-sm font-medium mb-1">
98 Email
99 </label>
100 <input
101 id="email"
102 type="email"
103 value={email}
104 onChange={(e) => setEmail(e.target.value)}
105 className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
106 required
107 />
108 </div>
109
110 <div>
111 <label htmlFor="password" className="block text-sm font-medium mb-1">
112 Password
113 </label>
114 <input
115 id="password"
116 type="password"
117 value={password}
118 onChange={(e) => setPassword(e.target.value)}
119 className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
120 required
121 minLength={8}
122 />
123 </div>
124
125 <div>
126 <label htmlFor="confirmPassword" className="block text-sm font-medium mb-1">
127 Confirm password
128 </label>
129 <input
130 id="confirmPassword"
131 type="password"
132 value={confirmPassword}
133 onChange={(e) => setConfirmPassword(e.target.value)}
134 className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
135 required
136 />
137 </div>
138
139 <button
140 type="submit"
141 disabled={isLoading}
142 className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
143 >
144 {isLoading ? 'Processing...' : 'Sign up'}
145 </button>
146 </form>
147
148 <div className="mt-6 text-center">
149 <p className="text-sm text-gray-600">
150 Already have an account?{' '}
151 <Link href="/auth/login" className="text-blue-600 hover:underline">
152 Log in
153 </Link>
154 </p>
155 </div>
156 </div>
157 );
158}1// app/auth/reset-password/page.tsx
2'use client';
3
4import { useState, FormEvent } from 'react';
5import { usePasswordReset } from '@/lib/passwordResetContext';
6import Link from 'next/link';
7
8export default function PasswordResetPage() {
9 const [email, setEmail] = useState('');
10 const { requestPasswordReset, isLoading, error, message } = usePasswordReset();
11
12 const handleSubmit = async (e: FormEvent) => {
13 e.preventDefault();
14 await requestPasswordReset(email);
15 };
16
17 return (
18 <div className="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
19 <h1 className="text-2xl font-bold mb-6 text-center">Password reset</h1>
20
21 {error && (
22 <div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">
23 {error}
24 </div>
25 )}
26
27 {message && (
28 <div className="mb-4 p-3 bg-green-100 text-green-700 rounded-md">
29 {message}
30 </div>
31 )}
32
33 <form onSubmit={handleSubmit} className="space-y-4">
34 <div>
35 <label htmlFor="email" className="block text-sm font-medium mb-1">
36 Email
37 </label>
38 <input
39 id="email"
40 type="email"
41 value={email}
42 onChange={(e) => setEmail(e.target.value)}
43 className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
44 required
45 disabled={isLoading || !!message} // Block after successful send
46 />
47 </div>
48
49 <button
50 type="submit"
51 disabled={isLoading || !!message}
52 className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
53 >
54 {isLoading ? 'Sending...' : 'Reset password'}
55 </button>
56 </form>
57
58 <div className="mt-6 text-center">
59 <p className="text-sm text-gray-600">
60 <Link href="/auth/login" className="text-blue-600 hover:underline">
61 Back to login page
62 </Link>
63 </p>
64 </div>
65 </div>
66 );
67}We can also create higher-order components (HOCs) that will protect access to pages based on authentication state:
1// components/withAuth.tsx
2'use client';
3
4import { useAuth } from '@/lib/authContext';
5import { useRouter } from 'next/navigation';
6import { useEffect, ComponentType } from 'react';
7
8// HOC do wymagania uwierzytelnienia
9export function withAuth<P extends object>(Component: ComponentType<P>) {
10 return function AuthProtected(props: P) {
11 const { isAuthenticated, isLoading } = useAuth();
12 const router = useRouter();
13
14 useEffect(() => {
15 if (!isLoading && !isAuthenticated) {
16 router.push(`/auth/login?callbackUrl=${encodeURIComponent(window.location.pathname)}`);
17 }
18 }, [isLoading, isAuthenticated, router]);
19
20 if (isLoading) {
21 return <div className="flex justify-center p-8">Loading...</div>;
22 }
23
24 if (!isAuthenticated) {
25 return null; // Avoid flicker before redirecting
26 }
27
28 return <Component {...props} />;
29 };
30}
31
32// HOC requiring a specific role
33export function withRoles<P extends object>(Component: ComponentType<P>, requiredRoles: string | string[]) {
34 return function RolesProtected(props: P) {
35 const { isAuthenticated, isLoading, hasRoles, user } = useAuth();
36 const router = useRouter();
37
38 useEffect(() => {
39 if (!isLoading) {
40 if (!isAuthenticated) {
41 router.push(`/auth/login?callbackUrl=${encodeURIComponent(window.location.pathname)}`);
42 } else if (!hasRoles(requiredRoles)) {
43 router.push('/auth/unauthorized');
44 }
45 }
46 }, [isLoading, isAuthenticated, hasRoles, router]);
47
48 if (isLoading) {
49 return <div className="flex justify-center p-8">Loading...</div>;
50 }
51
52 if (!isAuthenticated || !hasRoles(requiredRoles)) {
53 return null; // Avoid flicker before redirecting
54 }
55
56 return <Component {...props} />;
57 };
58}
59
60// HOC requiring specific permissions
61export function withPermission<P extends object>(Component: ComponentType<P>, requiredPermission: string | string[]) {
62 return function PermissionProtected(props: P) {
63 const { isAuthenticated, isLoading, hasPermission } = useAuth();
64 const router = useRouter();
65
66 useEffect(() => {
67 if (!isLoading) {
68 if (!isAuthenticated) {
69 router.push(`/auth/login?callbackUrl=${encodeURIComponent(window.location.pathname)}`);
70 } else if (!hasPermission(requiredPermission)) {
71 router.push('/auth/unauthorized');
72 }
73 }
74 }, [isLoading, isAuthenticated, hasPermission, router]);
75
76 if (isLoading) {
77 return <div className="flex justify-center p-8">Loading...</div>;
78 }
79
80 if (!isAuthenticated || !hasPermission(requiredPermission)) {
81 return null; // Avoid flicker before redirecting
82 }
83
84 return <Component {...props} />;
85 };
86}Example HOC usage:
1// app/admin/settings/page.tsx
2'use client';
3
4import { withRoles } from '@/components/withAuth';
5
6function AdminSettingsPage() {
7 return (
8 <div className="container mx-auto p-6">
9 <h1 className="text-2xl font-bold mb-6">Administrator settings</h1>
10 {/* Page content */}
11 </div>
12 );
13}
14
15// Export the component protected by the HOC
16export default withRoles(AdminSettingsPage, 'admin');
17
18// app/content/edit/page.tsx
19'use client';
20
21import { withPermission } from '@/components/withAuth';
22
23function EditContentPage() {
24 return (
25 <div className="container mx-auto p-6">
26 <h1 className="text-2xl font-bold mb-6">Content editing</h1>
27 {/* Page content */}
28 </div>
29 );
30}
31
32// Export the component protected by the HOC
33export default withPermission(EditContentPage, 'content:edit');Our AuthContext can also be integrated with a more complex application state management system such as Redux, Zustand, or Jotai. For example, integration with Zustand could look like this:
1// lib/authStore.ts
2import { create } from 'zustand';
3import { Session } from 'next-auth';
4
5type AuthState = {
6 session: Session | null;
7 status: 'loading' | 'authenticated' | 'unauthenticated';
8 error: string | null;
9 user: Session['user'] | null;
10 setSession: (session: Session | null) => void;
11 setStatus: (status: 'loading' | 'authenticated' | 'unauthenticated') => void;
12 setError: (error: string | null) => void;
13 hasRoles: (role: string | string[]) => boolean;
14 hasPermission: (permission: string | string[]) => boolean;
15 reset: () => void;
16};
17
18export const useAuthStore = create<AuthState>((set, get) => ({
19 session: null,
20 status: 'loading',
21 error: null,
22 user: null,
23
24 setSession: (session) => set({
25 session,
26 user: session?.user || null,
27 status: session ? 'authenticated' : 'unauthenticated'
28 }),
29
30 setStatus: (status) => set({ status }),
31
32 setError: (error) => set({ error }),
33
34 hasRoles: (role) => {
35 const { user } = get();
36 if (!user?.role) return false;
37
38 if (Array.isArray(role)) {
39 return role.includes(user.role as string);
40 }
41
42 return user.role === role;
43 },
44
45 hasPermission: (permission) => {
46 const { user } = get();
47 if (!user?.permissions) return false;
48
49 const userPermissions = user.permissions as string[];
50
51 if (Array.isArray(permission)) {
52 return permission.some(p => userPermissions.includes(p));
53 }
54
55 return userPermissions.includes(permission as string);
56 },
57
58 reset: () => set({
59 session: null,
60 status: 'unauthenticated',
61 error: null,
62 user: null
63 }),
64}));
65
66// Modified AuthProvider using Zustand
67export function AuthProvider({ children }: { children: ReactNode }) {
68 const { data: session, status } = useSession();
69 const { setSession, setStatus, setError } = useAuthStore();
70
71 // Update the store when the session changes
72 useEffect(() => {
73 setSession(session);
74 setStatus(status);
75 }, [session, status, setSession, setStatus]);
76
77 return <>{children}</>;
78}Testing Auth Context is key to ensuring the reliability of the authentication system. Below is an example of tests using React Testing Library and Jest:
1// __tests__/authContext.test.tsx
2import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3import { AuthProvider, useAuth } from '@/lib/authContext';
4import { SessionProvider } from 'next-auth/react';
5
6// Mock next-auth
7jest.mock('next-auth/react', () => ({
8 useSession: jest.fn(),
9 signIn: jest.fn(),
10 signOut: jest.fn(),
11}));
12
13// Test component using useAuth
14function TestComponent() {
15 const { isAuthenticated, user, hasRoles, hasPermission } = useAuth();
16
17 return (
18 <div>
19 <div data-testid="auth-status">
20 {isAuthenticated ? 'Authenticated' : 'Not authenticated'}
21 </div>
22 {user && <div data-testid="user-name">{user.name}</div>}
23 <div data-testid="admin-role">
24 {hasRoles('admin') ? 'Has admin role' : 'No admin role'}
25 </div>
26 <div data-testid="edit-permission">
27 {hasPermission('content:edit') ? 'Can edit content' : 'Cannot edit content'}
28 </div>
29 </div>
30 );
31}
32
33// Wrapper for tests
34function renderWithAuth(ui: React.ReactNode, sessionData: any = null) {
35 const useSessionMock = require('next-auth/react').useSession;
36
37 // Set up the mock for useSession
38 useSessionMock.mockReturnValue(sessionData);
39
40 return render(
41 <SessionProvider>
42 <AuthProvider>{ui}</AuthProvider>
43 </SessionProvider>
44 );
45}
46
47describe('AuthContext', () => {
48 it('should show unauthenticated state when no session exists', () => {
49 renderWithAuth(<TestComponent />, {
50 data: null,
51 status: 'unauthenticated'
52 });
53
54 expect(screen.getByTestId('auth-status')).toHaveTextContent('Not authenticated');
55 expect(screen.getByTestId('admin-role')).toHaveTextContent('No admin role');
56 expect(screen.getByTestId('edit-permission')).toHaveTextContent('Cannot edit content');
57 });
58
59 it('should show authenticated state with user data when session exists', () => {
60 renderWithAuth(<TestComponent />, {
61 data: {
62 user: {
63 name: 'Test User',
64 email: 'test@example.com',
65 role: 'user',
66 permissions: ['content:read']
67 }
68 },
69 status: 'authenticated'
70 });
71
72 expect(screen.getByTestId('auth-status')).toHaveTextContent('Authenticated');
73 expect(screen.getByTestId('user-name')).toHaveTextContent('Test User');
74 expect(screen.getByTestId('admin-role')).toHaveTextContent('No admin role');
75 expect(screen.getByTestId('edit-permission')).toHaveTextContent('Cannot edit content');
76 });
77
78 it('should correctly check for admin role', () => {
79 renderWithAuth(<TestComponent />, {
80 data: {
81 user: {
82 name: 'Admin User',
83 email: 'admin@example.com',
84 role: 'admin',
85 permissions: ['content:read', 'content:edit', 'user:manage']
86 }
87 },
88 status: 'authenticated'
89 });
90
91 expect(screen.getByTestId('auth-status')).toHaveTextContent('Authenticated');
92 expect(screen.getByTestId('admin-role')).toHaveTextContent('Has admin role');
93 expect(screen.getByTestId('edit-permission')).toHaveTextContent('Can edit content');
94 });
95
96 it('should correctly check for permissions', () => {
97 renderWithAuth(<TestComponent />, {
98 data: {
99 user: {
100 name: 'Editor User',
101 email: 'editor@example.com',
102 role: 'editor',
103 permissions: ['content:read', 'content:edit']
104 }
105 },
106 status: 'authenticated'
107 });
108
109 expect(screen.getByTestId('auth-status')).toHaveTextContent('Authenticated');
110 expect(screen.getByTestId('admin-role')).toHaveTextContent('No admin role');
111 expect(screen.getByTestId('edit-permission')).toHaveTextContent('Can edit content');
112 });
113});1// lib/useIdleTimer.ts
2import { useState, useEffect, useCallback } from 'react';
3
4type UseIdleTimerProps = {
5 timeout: number; // Time in milliseconds
6 onIdle: () => void;
7 onActive?: () => void;
8 events?: string[];
9};
10
11export function useIdleTimer({
12 timeout,
13 onIdle,
14 onActive,
15 events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']
16}: UseIdleTimerProps) {
17 const [isIdle, setIsIdle] = useState(false);
18 const [lastActive, setLastActive] = useState(Date.now());
19 const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
20
21 // Reset the inactivity timer
22 const resetTimer = useCallback(() => {
23 if (isIdle) {
24 setIsIdle(false);
25 onActive?.();
26 }
27
28 setLastActive(Date.now());
29
30 if (timeoutId) {
31 clearTimeout(timeoutId);
32 }
33
34 const id = setTimeout(() => {
35 setIsIdle(true);
36 onIdle();
37 }, timeout);
38
39 setTimeoutId(id);
40 }, [isIdle, onActive, onIdle, timeout, timeoutId]);
41
42 // Initialize the timer on mount
43 useEffect(() => {
44 resetTimer();
45
46 // Add event listeners
47 events.forEach(event => {
48 window.addEventListener(event, resetTimer);
49 });
50
51 // Clean up on unmount
52 return () => {
53 if (timeoutId) {
54 clearTimeout(timeoutId);
55 }
56
57 events.forEach(event => {
58 window.removeEventListener(event, resetTimer);
59 });
60 };
61 }, [events, resetTimer, timeoutId]);
62
63 return {
64 isIdle,
65 lastActive,
66 resetTimer
67 };
68}
69
70// Extending AuthProvider with automatic logout support
71export function AuthProvider({ children }: AuthProviderProps) {
72 // ... existing code
73
74 // Idle time: 30 minutes
75 const idleTimeout = 30 * 60 * 1000;
76 const [showIdleWarning, setShowIdleWarning] = useState(false);
77
78 // Handling logout after inactivity period
79 const handleIdle = () => {
80 // Show warning about impending logout
81 setShowIdleWarning(true);
82
83 // Give the user 1 minute to react before being logged out
84 setTimeout(() => {
85 if (showIdleWarning) {
86 handleSignOut({ callbackUrl: '/' });
87 }
88 }, 60 * 1000);
89 };
90
91 // Handling return to activity
92 const handleActive = () => {
93 setShowIdleWarning(false);
94 };
95
96 // Use the idle timer hook
97 useIdleTimer({
98 timeout: idleTimeout,
99 onIdle: handleIdle,
100 onActive: handleActive
101 });
102
103 // ... existing code
104
105 // Add inactivity warning modal
106 return (
107 <AuthContext.Provider value={value}>
108 {children}
109
110 {/* Inactivity warning modal */}
111 {showIdleWarning && (
112 <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
113 <div className="bg-white p-6 rounded-lg shadow-lg max-w-md">
114 <h2 className="text-xl font-bold mb-4">Inactivity detected</h2>
115 <p className="mb-4">You will be automatically logged out in 1 minute due to inactivity.</p>
116 <div className="flex justify-end space-x-4">
117 <button
118 onClick={() => handleSignOut({ callbackUrl: '/' })}
119 className="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400"
120 >
121 Log out now
122 </button>
123 <button
124 onClick={handleActive}
125 className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
126 >
127 Stay logged in
128 </button>
129 </div>
130 </div>
131 </div>
132 )}
133 </AuthContext.Provider>
134 );
135}1// lib/delegatedAuth.ts
2import { useAuth } from './authContext';
3import { useState } from 'react';
4
5type DelegatedToken = {
6 token: string;
7 expiresAt: number;
8 service: string;
9};
10
11export function useDelegatedAuth() {
12 const { isAuthenticated } = useAuth();
13 const [delegatedTokens, setDelegatedTokens] = useState<Record<string, DelegatedToken>>({});
14 const [isLoading, setIsLoading] = useState(false);
15 const [error, setError] = useState<string | null>(null);
16
17 // Acquire a token for the external service
18 const getDelegatedToken = async (service: string): Promise<string | null> => {
19 // If the user is not logged in, a token cannot be acquired
20 if (!isAuthenticated) {
21 setError('You must be logged in to access the external service');
22 return null;
23 }
24
25 // Check if we have a valid token in the cache
26 const cachedToken = delegatedTokens[service];
27 if (cachedToken && cachedToken.expiresAt > Date.now()) {
28 return cachedToken.token;
29 }
30
31 // Acquire a new token
32 setIsLoading(true);
33 setError(null);
34
35 try {
36 const response = await fetch(`/api/auth/delegate/${service}`, {
37 method: 'POST',
38 });
39
40 if (!response.ok) {
41 const data = await response.json();
42 throw new Error(data.message || 'Error acquiring access token');
43 }
44
45 const { token, expiresIn } = await response.json();
46 const expiresAt = Date.now() + expiresIn * 1000;
47
48 // Cache the token
49 const newToken = { token, expiresAt, service };
50 setDelegatedTokens(prev => ({ ...prev, [service]: newToken }));
51
52 return token;
53 } catch (err) {
54 setError(err instanceof Error ? err.message : 'An unknown error occurred');
55 return null;
56 } finally {
57 setIsLoading(false);
58 }
59 };
60
61 // Clear all delegated tokens
62 const clearDelegatedTokens = () => {
63 setDelegatedTokens({});
64 };
65
66 return {
67 getDelegatedToken,
68 clearDelegatedTokens,
69 isLoading,
70 error,
71 };
72}
73
74// Example usage in a component
75// components/ExternalServiceWidget.tsx
76'use client';
77
78import { useDelegatedAuth } from '@/lib/delegatedAuth';
79import { useState, useEffect } from 'react';
80
81export function ExternalServiceWidget() {
82 const { getDelegatedToken, isLoading, error } = useDelegatedAuth();
83 const [data, setData] = useState(null);
84
85 const fetchData = async () => {
86 // Acquire a token for the external service
87 const token = await getDelegatedToken('external-api');
88
89 if (!token) return;
90
91 try {
92 // Use the token to call the external service's API
93 const response = await fetch('https://api.external-service.com/data', {
94 headers: {
95 Authorization: `Bearer ${token}`
96 }
97 });
98
99 const result = await response.json();
100 setData(result);
101 } catch (err) {
102 console.error('Error fetching from external service:', err);
103 }
104 };
105
106 useEffect(() => {
107 fetchData();
108 }, []);
109
110 return (
111 <div className="bg-white p-4 rounded shadow">
112 <h2 className="text-lg font-bold mb-2">Data from the external service</h2>
113
114 {isLoading && <p>Loading data...</p>}
115 {error && <p className="text-red-600">{error}</p>}
116
117 {data && (
118 <div className="mt-2">
119 {/* Display data */}
120 </div>
121 )}
122
123 <button
124 onClick={fetchData}
125 disabled={isLoading}
126 className="mt-4 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
127 >
128 Refresh data
129 </button>
130 </div>
131 );
132}When implementing AuthContext, keep in mind several key security aspects:
Do not store sensitive data in state - avoid storing passwords, full JWT tokens, or API keys in a client-accessible context.
Validate data on the server side - always verify user permissions on the server, never rely solely on client-side validation.
Use HTTPS - all authentication-related requests must be transmitted over HTTPS.
Handle tokens securely - store tokens in httpOnly cookies, not localStorage or sessionStorage.
Implement CSRF protection - use CSRF tokens to protect against Cross-Site Request Forgery attacks.
Avoid information leaks - do not reveal detailed authentication error information that could help attackers.
Regularly refresh and rotate tokens - implement token refresh and rotation mechanisms.
In this module, we discussed the design and implementation of a Context Provider for authentication in Next.js applications, specifically:
A well-designed Context Provider for authentication significantly simplifies authentication state management across the entire application, centralizes logic, and provides a consistent interface for using security-related functions.
In the next module, we will look at application security aspects and OWASP best practices that will help us create more secure and attack-resistant Next.js applications.