Implementando la Autenticación en Next.js: Una Guía Paso a Paso [REPO]

En el artículo de hoy, nos sumergiremos en el mundo de Next.js para entender cómo implementar la autenticación y, de esta manera, poder conectarnos de forma segura a nuestra API.

Nuestro objetivo es establecer un sistema robusto y seguro que proteja nuestros datos y los de nuestros usuarios.

Para lograr este objetivo, comenzaremos implementando un middleware. Este middleware actuará como un guardián, protegiendo nuestras rutas y asegurando que solo los usuarios autenticados tengan acceso a ellas. Este es un paso crucial para asegurar que nuestra aplicación solo sea accesible para aquellos que tienen permiso para usarla.

A continuación, crearemos los formularios de inicio de sesión y registro. Estos formularios son la puerta de entrada para nuestros usuarios, por lo que es esencial que proporcionen una experiencia de usuario fluida y segura. Explicaremos cómo diseñar estos formularios para garantizar que sean fáciles de usar y que cumplan con nuestras necesidades de seguridad.

Finalmente, guardaremos una cookie encriptada en el navegador del usuario. Esta cookie será la llave que permitirá a nuestros usuarios autenticados realizar llamadas a la API. Asegurarse de que esta cookie esté correctamente encriptada es vital para mantener la seguridad de nuestros usuarios y de nuestra aplicación.

Puedes ver el proyecto desplegado en Codesandbox y el repo público en GitHub:

https://codesandbox.io/p/github/juannunezblasco/next-auth/main

https://github.com/juannunezblasco/next-auth

Cómo configurar autenticación con Next.js

1. Proteger rutas

// middleware.ts 
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|assets|images|styles|icons|favicon.ico|sw.js).*)']
}

export async function middleware (request: NextRequest) {

  const cookie = request.cookies.get('accessToken')?.value ?? ''

  const token = await decrypt(cookie);

  if (!/^\/auth\//.test(request.nextUrl.pathname) && !token) {
    console.log('redirecting to login')
    return NextResponse.redirect(new URL('/auth/login', request.url))
  }
  
  if (/^\/auth\//.test(request.nextUrl.pathname) && token) {
    return NextResponse.redirect(new URL('/', request.url))
  }

  return NextResponse.next()

}

El bloque de código anterior muestra cómo se puede implementar un middleware en Next.js para proteger las rutas de la aplicación. En este caso, el middleware revisa si existe una cookie llamada ‘accessToken’ en el navegador del usuario.

Si la cookie existe y está encriptada, el usuario puede continuar navegando por la aplicación. Si la cookie no existe o no está encriptada, el middleware redirige al usuario a la página de login.

Este middleware también verifica que si un usuario ya está autenticado (es decir, tiene un token), no pueda acceder a las rutas de autenticación (como la de login), redirigiéndolo a la página principal en cambio.

2. Encriptación de Cookies

// middlewares/auth/token.ts
'use server';

import { JWTPayload, SignJWT, jwtVerify } from "jose";

const COOKIE_SECRET_KEY = 'nPt1cx%VyphXrz8waX#2*KW%ZoCI1S8oymrv%78Mnr'; // Change this to your own secret key come from .env file
const key = new TextEncoder().encode(COOKIE_SECRET_KEY);

export async function encrypt(payload: JWTPayload, expires: Date) {

  return await new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime(new Date( expires ))
    .sign(key);
}

export async function decrypt(input: string): Promise<JWTPayload | null> {

  try {
    const { payload } = await jwtVerify(input, key, {
      algorithms: ["HS256"],
    });
    return payload;
    
  } catch (error) {
    return null;
  }
}

Este bloque de código muestra cómo encriptar y desencriptar una cookie en Next.js para mantener segura la información del usuario. El método encrypt toma un payload y una fecha de expiración como argumentos, crea un nuevo JWT y lo firma. El método decrypt toma una cadena de entrada, verifica el JWT y devuelve el payload si la verificación es exitosa. En caso de error, devuelve null. La clave de encriptación COOKIE_SECRET_KEY debe ser una cadena segura y única.

3. Formularios, validaciones y acciones

// modules/auth/components/sign-in-form.tsx
'use client'

import { useFormState } from "react-dom";

import { ButtonSubmit } from "@/modules/common/components/molecules/button-submit";
import { signInAction } from "../../actions/sign-in";
import { FalRightToBranket } from "@/assets/icons/fal-right-to branket";

export const SignInForm = () => {

  const [state, formAction] = useFormState(signInAction, undefined); 
  
  return (
    <form 
      id="sign-in-form"
      data-testid="sign-in-form"
      className="flex flex-col gap-4 w-80 max-w-full"
      action={formAction}
    >
      <div className='flex flex-col'>
        <label htmlFor="email">Email</label>
        <input 
          id="email" 
          name="email"
          placeholder="Email" 
        />
      </div>
      {state?.errors?.email && <p>{state.errors.email}</p>}
      <div className='flex flex-col'>
        <label htmlFor="password">Password</label>
        <input 
          id="password" 
          name="password"
          type="password" 
          placeholder="Password"
        />  
      </div>
      {state?.errors?.password && (
        <div>
          <p>Password must:</p>
          <ul>
            {state.errors.password.map((error) => (
              <li key={error}>- {error}</li>
            ))}
          </ul>
        </div>
      )}
      <div className="mb-4 w-full flex justify-center">
        <ButtonSubmit>
          <FalRightToBranket className="w-5 h-auto mr-2" />
          Login
        </ButtonSubmit>
      </div>
    </form>
  )
}

El bloque de código anterior representa un formulario de inicio de sesión en Next.js. Utiliza el hook useFormState para manejar el estado del formulario y la acción de inicio de sesión signInAction. El formulario consta de dos campos: Email y Password, cada uno con su etiqueta correspondiente y un espacio para mostrar errores de validación. Finalmente, hay un botón de envío para enviar el formulario.

// modules/auth/actions/sign-in.ts
'use server '

import { SigninFormSchema, SigninFormState } from "../domain/sign-in-form-schema";
import { setAuthCookie } from "../hooks/use-set-cookie";

export const signInAction = async (state: SigninFormState, formData: FormData) => {
  // Validate form fields
  const validatedFields = SigninFormSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  })
  
  // If any form fields are invalid, return early
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  // Call the provider or db to signin a user...

  const tomorrow = new Date();
  tomorrow.setDate(tomorrow.getDate() + 1);

  // Use the `tomorrow` date in your code
  setAuthCookie({
    token: 'access-token', // This should be the token you get from the server
    expiresAt: tomorrow
  });

}

El bloque de código anterior muestra cómo manejar la acción de inicio de sesión en Next.js. Primero, valida los campos del formulario utilizando un esquema definido (SigninFormSchema). Si algún campo no es válido, se devuelve un objeto con los errores de validación correspondientes.

// modules/auth/domain/sign-in-form-schema.ts

import { z } from 'zod'
 
export const SigninFormSchema = z.object({
  email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
  password: z
    .string()
    .min(8, { message: 'Be at least 8 characters long' })
    .regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
    .regex(/[0-9]/, { message: 'Contain at least one number.' })
    .regex(/[^a-zA-Z0-9]/, {
      message: 'Contain at least one special character.',
    })
    .trim(),
})
 
export type SigninFormState =
  | {
      errors?: {
        email?: string[]
        password?: string[]
      }
      message?: string
    }
  | undefined

Si todos los campos son válidos, se realiza una llamada al proveedor o a la base de datos para iniciar sesión de un usuario (esta parte del código no está mostrada).

Finalmente, se establece una cookie de autenticación (setAuthCookie) con un token (en este ejemplo, una cadena de texto ‘access-token’, pero en un caso real, sería el token que se obtiene del servidor) y una fecha de expiración (en el ejemplo, el día siguiente al actual).

// modules/auth/hooks/use-set-cookie.tsx
'use server'

import { encrypt } from '@/middlewares/auth/token';
import { cookies } from 'next/headers';
import { redirect} from 'next/navigation';

export const setAuthCookie = async (state: { token: string, expiresAt: Date }) => {

  const encryptedToken = await encrypt( {
    ...state
  }, state.expiresAt )

  cookies().set({
    name: 'accessToken',
    value: encryptedToken,
    expires: state.expiresAt,
    path: '/',
    sameSite: 'strict',
    secure: process.env.NODE_ENV === 'production',
  })

  redirect('/')

}

El bloque de código anterior muestra cómo establecer una cookie de autenticación en Next.js. En primer lugar, encripta el token y la fecha de expiración con la función encrypt. Luego, configura la cookie con el nombre ‘accessToken’, el token encriptado como valor, la fecha de expiración, la ruta ‘/’, una política de ‘sameSite’ estricta y una configuración segura si el entorno es de producción. Finalmente, redirige al usuario a la página principal.

4. Tests

Configuración básica para testing con Jest y Testing Library.

// __tests__/app/auth/login.test.txs

import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import Login from '@/app/auth/login/page'
import { SignInForm } from '@/modules/auth/components/sign-in-form/sign-in-form'
 


// Mock the component you want to test if it's rendered
jest.mock('@/modules/auth/components/sign-in-form/sign-in-form', () => {
  return {
    __esModule: true,
    SignInForm: () => {
      return <div>Mocked Component</div>
    },
  }
})

describe('Login page', () => {
  it('renders a heading', () => {
    render(<Login />)
 
    const heading = screen.getByRole('heading', { level: 1 })
 
    expect(heading).toBeInTheDocument()
  })

  it('renders the SignInForm component', () => {
    render(<Login />)
 
    const signInForm = screen.getByText('Mocked Component')
 
    expect(signInForm).toBeInTheDocument()
  })
  
})

El bloque de código anterior corresponde a un conjunto de pruebas unitarias para una página de inicio de sesión en Next.js, utilizando las bibliotecas Jest y Testing Library.

Se inicia importando las bibliotecas necesarias y el componente que se va a testear. Luego, se utiliza jest.mock para crear un ‘mock’ del componente SignInForm, que se renderiza como un div con el texto ‘Mocked Component’.

El test suite contiene dos pruebas:

  1. La primera prueba verifica que la página de inicio de sesión renderiza un encabezado. Para ello, renderiza el componente Login y luego busca un elemento con el rol de encabezado en el nivel 1. Si este elemento está en el documento, la prueba pasa.
  2. La segunda prueba verifica que el componente SignInForm se renderiza en la página de inicio de sesión. Renderiza el componente Login y luego busca un elemento con el texto ‘Mocked Component’. Si este elemento está en el documento, la prueba pasa.

Conclusiones

Este artículo proporciona un ejemplo básico de cómo implementar la autenticación en Next.js. Abarca desde la protección de las rutas, la encriptación de las cookies, la creación de formularios de inicio de sesión y registro, hasta la realización de pruebas.

Si te ha servido de ayuda te animo a que lo compartas en tus redes.

Juan Núñez Blaco

Full Stack Developer