We use cookies to enhance your experience on the site
CodeWorlds

Context Provider for authentication

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.

Why is it worth creating your own Context Provider for authentication?

NextAuth.js already provides

SessionProvider
, which provides basic session information. However, it is often worth extending this functionality with additional capabilities:

  1. Centralization of authentication logic - all functions related to authentication in one place
  2. Additional methods - e.g. checking permissions, handling registration, password reset
  3. Loading state - easier management of session loading state
  4. Error handling - consistent authentication error management
  5. Typing contract - a clearly defined interface for TypeScript

Implementing AuthContext and AuthProvider

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}

Integrating AuthProvider with the application

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}

Creating PasswordResetProvider as an example of a specialized provider

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}

Extending AuthProvider with additional features

We can extend our AuthProvider with additional features that will often be needed in applications:

1. User registration

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}

2. User profile update

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}

3. Handling two-factor authentication (2FA)

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}

Using AuthContext in components

Now we can use our AuthContext in application components:

1. Login/logout button component

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}

2. Protected page component

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}

3. Registration form with validation

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}

4. Password reset form

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}

Creating higher-order components (HOC) to protect pages

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');

Integration with application state management system

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 AuthContext

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

Advanced AuthContext use cases

1. Inactivity detection and automatic logout

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}

2. Implementing access delegation mechanism (token swapping)

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}

Authentication context security

When implementing AuthContext, keep in mind several key security aspects:

  1. Do not store sensitive data in state - avoid storing passwords, full JWT tokens, or API keys in a client-accessible context.

  2. Validate data on the server side - always verify user permissions on the server, never rely solely on client-side validation.

  3. Use HTTPS - all authentication-related requests must be transmitted over HTTPS.

  4. Handle tokens securely - store tokens in httpOnly cookies, not localStorage or sessionStorage.

  5. Implement CSRF protection - use CSRF tokens to protect against Cross-Site Request Forgery attacks.

  6. Avoid information leaks - do not reveal detailed authentication error information that could help attackers.

  7. Regularly refresh and rotate tokens - implement token refresh and rotation mechanisms.

Summary

In this module, we discussed the design and implementation of a Context Provider for authentication in Next.js applications, specifically:

  1. Creating AuthContext and AuthProvider - centralizing authentication logic in one place
  2. Integration with NextAuth.js - using existing authentication mechanisms
  3. Extending with additional features - registration, profile management, 2FA
  4. Specialized providers - handling specific use cases like password reset
  5. Higher-order components (HOC) - protecting pages based on roles and permissions
  6. Integration with state management systems - combining with Zustand and other libraries
  7. Testing - writing tests for AuthContext
  8. Advanced use cases - automatic logout and access delegation

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.

Go to CodeWorlds