Enero 19, 2024
Matias Vessuri
En el mundo actual del desarrollo web, la necesidad de un mecanismo de autenticación robusto se ha vuelto un requisito común. Muchos sitios y aplicaciones web de todos los tamaños requieren controles de acceso seguros para proteger información sensible y garantizar una experiencia de usuario personalizada. Incluso en escenarios donde las restricciones de acceso no son realmente necesarias, la autenticación puede utilizarse como un mecanismo para identificar y personalizar las interacciones de cada usuario, mejorando así la experiencia y participación de los usuario.
Ahora, supongamos que para tu proyecto decidiste utilizar Next.js como framework para construir aplicaciones React.js. Por defecto, este framework no proporciona un mecanismo de autenticación, pero afortunadamente, contamos con un excelente proyecto de código abierto llamado Auth.js que llena ese vacío y ofrece una solución completa de autenticación para aplicaciones web en JavaScript.
Presentando NextAuth.js
NextAuth.js es una solución de autenticación para Next.js. Esta solución ha evolucionado más allá de su objetivo inicial de proporcionar autenticación exclusivamente para Next.js y actualmente está en medio de un rebranding estratégico de NextAuth.js a Auth.js. Esto significa que está en proceso de convertirse en una biblioteca de autenticación que puede soportar una variedad de frameworks JavaScript.
Auth.js amplía su alcance más allá de Next.js, con el objetivo de convertirse en una solución de autenticación para varios frameworks. Como parte de esta expansión, adopta compatibilidad no solo con Next.js, sino también con otros frameworks como Sveltekit y SolidStart, con futuras integraciones que incluyen Express, Remix, Astro y Nuxt.
Este artículo explora las capacidades de NextAuth.js versión 4, su funcionalidad, implementación y cómo podría utilizarse para crear Control de Acceso Basado en Roles en una aplicación Next.js.
Autenticación para una aplicación con el App Router de Next.js
En este artículo, repasaremos el proceso para implementar autenticación con GitHub como proveedor de autenticación en una aplicación Next.js que utiliza el relativamente nuevo App Router. Para este propósito, haremos uso de los Route Handlers de Next.js para gestionar todas las solicitudes necesarias para autenticarse en nuestra aplicación.
Los conceptos y la mayoría de los pasos en este artículo son aplicables a cualquier aplicación Next.js 13 o superior que puedas tener. También creamos un repositorio de Git que puede servirte como guía o para hacer pruebas. El repositorio utiliza Next.js 14, Tailwind CSS, el directorio src y App Router.
Para comenzar, debemos instalar el paquete next-auth. Para estos ejemplos, usaremos npm, pero puedes utilizar Yarn u otro gestor de paquetes.
npm install next-auth
El siguiente paso es crear un archivo llamado route.js dentro del directorio /src/app/api/auth/[...nextauth]/
. Al crear esta estructura de directorios, ten cuidado de no omitir ninguno de los corchetes o puntos, ya que son necesarios para que Next.js pueda generar dinámicamente las rutas requeridas.
Desde la raíz del proyecto, puedes usar el siguiente comando
mkdir -p 'src/app/api/auth/[...nextauth]'
Más adelante volveremos a ver este archivo, pero primero debemos decidir qué métodos de autenticación queremos utilizar.
Proveedores de Inicio de Sesión
NextAuth.js admite una variedad de métodos de autenticación. Tiene soporte integrado para integrarlo con una larga lista de proveedores de autenticación que incluyen todas las redes sociales populares como Google, Facebook, Twitter e Instagram. También funciona con otros servicios como Auth0, Keycloak y Cognito, entre muchos otros. Si el proveedor que deseas utilizar no forma parte de la lista de proveedores soportados, hay un proveedor OAuth/OpenID genérico que puedes utilizar para crear un proveedor de OAuth personalizado.
Utilizar un proveedor de autenticación es el método preferido, pero NextAuth.js también proporciona dos métodos alternativos de autenticación. Una solución de autenticación por correo electrónico que permite a los usuarios autenticarse mediante “enlaces mágicos” enviados a su correo electrónico, y una autenticación de credenciales que ofrece una forma de integrar un servicio de autenticación existente con nombres de usuario y contraseñas tradicionales, autenticación de dos factores o un dispositivo de hardware.
En nuestra aplicación Next.js, debemos definir la configuración utilizada para inicializar NextAuth.js. Colocaremos la configuración en un archivo separado dentro del directorio src/app/api/auth/[...nextauth]
. Este enfoque nos permite mantener las cosas organizadas, ya que necesitaremos importar esta configuración en más de un lugar dentro de la aplicación Next.js.
Voy a nombrar el archivo que contiene la configuración como “options.ts”, pero puedes elegir cualquier nombre que prefieras. Dentro de este archivo, exportaremos un objeto de opciones que contiene toda la configuración necesaria para los diferentes tipos de autenticación admitidos por nuestra aplicación. Hay muchas cosas que se pueden configurar en este objeto de opciones, pero la única realmente requerida es un array de proveedores, con uno o más proveedores de autenticación para usar en la aplicación.
Ahora, veamos esto en práctica. En nuestro archivo options.ts, primero incluimos el proveedor que queremos usar y creamos un objeto de opciones con la configuración. En el ejemplo, usaré GitHub, ya que es uno de los más fáciles de configurar, pero puedes consultar la documentación de Auth.js para ver los requisitos de configuración para otros proveedores de autenticación.
import GithubProvider from 'next-auth/providers/github'
export const authOptions = {
providers: [
GithubProvider({
clientId: process.env.GITHUB_ID as string,
clientSecret: process.env.GITHUB_SECRET as string,
}),
],
}
GitHub solo requiere dos valores de configuración: un ID de cliente y un secreto de cliente. Para obtener estos valores, primero debes crear una aplicación de GitHub. Puedes crear una aplicación en tu cuenta personal visitando https://github.com/settings/apps. También es posible crear una aplicación para una de tus organizaciones.
Durante la creación de la aplicación, deberás asignarle un nombre, configurar una página de inicio y establecer una URL de callback. Para el desarrollo local, utiliza http://localhost:3000/api/auth/callback/github como la URL de callback. Después de crear la aplicación, se te proporcionará el ID de cliente y podrás generar un secreto de cliente. Guarda ambos, ya que los usaremos en breve.
Hay una cosa más que necesita el objeto de opciones: una cadena secreta aleatoria utilizada para hashear tokens, cookies y claves. Técnicamente, este secreto no es necesario durante el desarrollo, ya que en este ambiente por defecto es el hash SHA del objeto de opciones. Sin embargo, es necesario en producción y tomará el valor de la variable de entorno NEXTAUTH_SECRET. Por lo tanto, realmente no es necesario configurarlo en el archivo options.ts.
Variables de entorno
Es una buena práctica no incluir información sensible como parte del código de tu aplicación y, en su lugar, utilziar variables de entorno. Crearemos un archivo .env.local para almacenar nuestras credenciales de autenticación de los diferentes proveedores durante el desarrollo. Existe una regla en el archivo .gitignore para evitar que este archivo se incluya en Git.
A continuación hay un ejemplo de como se verá tu archivo .env.local, pero deberás completarlo con tus credenciales.
# Next Auth Config
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET=""
# GitHub Config
GITHUB_ID="YOUR_GITHUB_CLIENT_ID"
GITHUB_SECRET="YOUR_GITHUB_CLIENT_SECRET"
Como mencionamos anteriormente, el NEXTAUTH_SECRET no es estrictamente necesario durante el desarrollo. Sin embargo, recomiendo configurarlo desde el principio. Esto nos asegura tener una clave consistente para hashear tokens y cookies, incluso si realizamos cambios en las opciones de la aplicación. También ayuda a evitar el riesgo de olvidar configurarlo más adelante.
Puedes crear un buen valor aleatoreo para NEXTAUTH_SECRET utilizando el siguiente comando:
openssl rand -base64 32
Definición Route Handlers para NextAuth.js
El siguiente paso es crear todas las rutas necesarias para que NextAuth.js pueda manejar la autenticación. Afortunadamente, gracias a los Route Handlers de Next.js, esto es muy fácil. Creamos un archivo route.ts dentro del directorio /src/app/api/auth/[…nextauth]/, el mismo directorio que creamos previamente para almacenar el archivo options.ts.
En este archivo importamos NextAuth y el objeto de opciones que definimos anteriormente. Al inicializar NextAuth con estas opciones, obtenemos un manejador de rutas. Finalmente, exportamos el manejador como GET y como POST, ya que NextAuth necesitará manejar ambos tipos de solicitudes para funcionar correctamente.
import NextAuth from "next-auth"
import { authOptions } from "./options";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST }
Este código asegura que nuestras rutas de autenticación estén configuradas y listas para manejar las solicitudes necesarias sin problemas. No verás cambios en la aplicación, ya que no configuramos reglas para el control de acceso, pero si vas directamente a http://localhost:3000/api/auth/signin, podrás iniciar sesión con GitHub.
Accediendo a los Datos de la Sesión y Protegiendo Páginas
Ahora que hemos configurado la autenticación, nos gustaría acceder a los datos de la sesión y proteger el acceso a páginas y rutas de API. Cómo hacemos esto varía según el lugar donde queramos acceder a la sesión.
Componentes del Servidor y Route Handlers
Para componentes del servidor o Route Handlers, utilizamos la función getServerSession proporcionada por la biblioteca. Para utilizar la función getServerSession debemos pasarle el mismo objeto de opciones que definimos anteriormente y que también se utilizó para inicializar NextAuth y configurar los Route Handlers para Auth.js. Esta es la razón por la cual colocamos ese objeto de opciones en su propio archivo, para que podamos importarlo fácilmente en diferentes lugares.
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/options";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) {
return Response.json({ message: "Not authenticated" }, { status: 401 })
}
return Response.json({ message: "Hello from the API" }, { status: 200 })
}
getServerSession devuelve la sesión para usuarios autenticados y null si el usuario no está autenticado. Podemos utilizar esto para permitir o denegar el acceso a una API o a una página.
Páginas y Componentes del Cliente
Para el lado del cliente, NextAuth proporciona un Hook de React llamado useSession() que podemos utilizar para acceder a los datos de la sesión y comprobar si alguien ha iniciado sesión o no.
El hook useSession() devuelve un objeto que contiene los datos de la sesión y el estado. El estado se puede utilizar para saber si un usuario ha iniciado sesión, ya que puede uno de los siguientes tres estados: “loading” (cargando), “authenticated” (autenticado) o “unauthenticated” (no autenticado). Para usuarios autenticados, los datos contendrán la información de la sesión, incluyendo de forma predeterminada el nombre y el correo electrónico del usuario.
Pero antes de implementar esto, debemos configurar un componente SessionProvider que envuelva a todos los demás componentes. Al utilizar el Contexto de React, el componente
Podemos hacer esto de diferentes maneras, pero para este ejemplo vamos a crear un simple componente del lado del cliente llamado AuthProvider que se utilizará para envolver todo el contenido en nuestro Layout de Next.js. Llamaremos a este componente NextAuthProvider.
#/src/app/context/NextAuthProvider.tsx
'use client'
import { SessionProvider } from 'next-auth/react'
export default function NextAuthProvider({ children }: {
children: React.ReactNode
}) {
return (
<SessionProvider>
{children}
</SessionProvider>
)
}
Y el Root Layout se tomará la siguiente forma:
#/src/app/layout.tsx
import NextAuthProvider' from './context/NextAuthProvider'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<NextAuthProvider>
{children}
<NextAuthProvider>
</body>
</html>
)
}
Ahora que tenemos toda esta configuración, podemos utilizar el hook useSession para acceder a los datos de la sesión en cualquier componente del cliente en nuestra aplicación y utilizar esa información para restringir el acceso o realizar alguna acción.
"use client"
import { useSession, signIn, signOut } from "next-auth/react"
export default function ClientComponent() {
const { data: session } = useSession()
if (session) {
return (
<>
<h2>Session Data:</h2>
<pre>{JSON.stringify(session, null, 2)}</pre>
<button onClick={() => signOut()}>Sign out</button>
</>
)
}
return (
<>
Not signed in <br />
<button onClick={() => signIn()}>Sign in</button>
</>
)
}
Middleware
Next.js 12 introdujo el concepto de middleware. Esta nueva característica te permite ejecutar código en el lado del servidor antes de que se ejecuten las funciones reales de tus páginas. Es el lugar perfecto para manejar la autenticación y el control de acceso a nivel de página antes de que se renderizen las páginas.
El middleware en Next.js es una función que recibe un objeto NextRequest y devuelve un objeto NextResponse o una Promise que se resuelve en un objeto NextResponse. Se puede utilizar para modificar los objetos de solicitud o respuesta, o para decidir qué hacer a continuación según la solicitud.
Una de las limitaciones de NextAuth.js y el middleware es que, en la versión actual, solo funciona con tokens jwt y no con sesiones de base de datos. Volveremos sobre esto más adelante, en la sección de adaptadores de base de datos.
Desde Next.js 13, la función de middleware se puede definir dentro de un archivo middleware.ts (o middleware.js) almacenado en la carpeta src. Aquí tendremos la lógica para el acceso a las páginas de nuestra aplicación.
Si queremos restringir el acceso a todas las páginas excepto a usuarios autenticados, solo tenemos que agregar esto al archivo de middleware.
export { default } from "next-auth/middleware"
Si solo quieres que ciertas páginas requieran autenticación para acceder a ellas, puedes usar el matcher para filtrar el middleware y hacer que solo se ejecute en las páginas que deseas. Aquí tienes un ejemplo sencillo, pero puedes tener múltiples rutas y usar parámetros o expresiones regulares en ellas.
export const config = {
matcher: '/private-page',
}
Veremos un ejemplo de una función de middleware más compleja cuando exploremos el control de acceso basado en roles.
Adaptadores de Base de Datos
Hasta ahora no hemos configurado una base de datos y, como puedes ver, NextAuth.js puede funcionar sin una. Logra esto creando sesiones mediante tokens JSON Web Tokens (JWT), que son cookies cifradas que contienen los datos de la sesión, y son almacenados en el navegador del cliente.
Este enfoque funciona bien y, en muchos casos, será suficiente. Sin embargo, si deseas tener un lugar para almacenar datos para los usuarios o tener sesiones del lado del servidor, necesitarías configurar un adaptador de base de datos.
Existen varios adaptadores oficiales proporcionados actualmente por el proyecto NextAuth.js, incluidos adaptadores para MongoDB, PostgreSQL, Prisma y Firebase. También puedes escribir tu propio adaptador si tu base de datos aún no es compatible.
En nuestro caso, necesitamos la base de datos para tener la capacidad de agregar un rol a cada usuario, ya que nuestro proveedor de autenticación (GitHub) no maneja esto. Otros proveedores de autenticación, como Auth0 y Keycloak, admiten el Control de Acceso Basado en Roles, y si te sientes cómodo utilizando JWT, puede que no sea necesario configurar una base de datos.
Con fines de demostración, configuraremos una simple base de datos SQLite utilizando Prisma como ORM. SQLite es una base de datos basada en archivos que no requiere un servidor de base de datos separado.
Primero, instalamos las dependencias de Prisma en nuestro proyecto.
npm install @prisma/client @next-auth/prisma-adapter
npm install prisma --save-dev
Luego, debemos crear un esquema para nuestra base de datos. Hacemos esto creando un archivo schema.prisma dentro del directorio prisma. Este archivo contiene tanto la información sobre la conexión a la base de datos como el modelo de datos.
datasource db {
provider = "sqlite"
url = "file:./dev.sqlite"
}
generator client {
provider = "prisma-client-js"
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
refresh_token_expires_in Int?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
Ahora podemos generar el cliente de Prisma y configurar la base de datos con el esquema definido anteriormente utilizando el comando de migración.
npx prisma generate
npx prisma migrate dev
Finalmente, agregamos el adaptador de base de datos al objeto de opciones de NextAuth.
import { PrismaAdapter } from "@next-auth/prisma-adapter"
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export const authOptions = {
adapter: PrismaAdapter(prisma),
providers: [
// Add your chosen authentication providers here
],
}
Al configurar un adaptador de base de datos, NextAuth.js establecerá por defecto que el comportamiento de la sesión sea la base de datos. Por lo tanto, si deseamos utilizar middleware y configuramos un adaptador de base de datos, debemos cambiar el objeto de opciones y establecer el comportamiento de la sesión a jwt.
export const authOptions = {
session: {
strategy: "jwt",
},
adapter: PrismaAdapter(prisma),
Control de Acceso Basado en Roles
Hasta ahora, hemos realizado autenticación, verificando que el usuario sea quien dice ser. Para algunas aplicaciones, como una red social, esta verificación podría ser suficiente. Sin embargo, en muchos otros escenarios, queremos determinar si el usuario tiene el derecho de acceder a una página específica o realizar una tarea particular. Un método común para lograr esto es el Control de Acceso Basado en Roles. Los roles se definen según la autoridad y la responsabilidad dentro de la aplicación, y esos roles se asignan a usuarios, otorgándoles permisos para realizar ciertas acciones.
Los roles de usuario podrían provenir directamente de nuestro proveedor de autenticación. Sin embargo, para este ejemplo, estamos utilizando GitHub, un proveedor que no admite la gestión de roles. En cambio, almacenaremos el rol para cada usuario en la base de datos como parte del modelo de usuario.
Para lograr esto, agregamos una columna o campo “role” al modelo de usuario en nuestra base de datos. Estamos utilizando Prisma, pero esto funciona de manera similar con otros tipos de bases de datos; la única variación es cómo agregar ese campo o columna para el rol.
Veamos cómo hacer esto con Prisma. Para mantener las cosas simples, definiremos que en nuestra aplicación cada usuario solo puede tener un rol, pero puedes ampliar esto para permitir múltiples roles por usuario.
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
role String?
accounts Account[]
sessions Session[]
}
Añadimos la dfinición para el rol a nuestro esquema y ejecutamos el comando de migración de Prisma para agregar la columna añadida a la base de datos.
npx prisma migrate dev --name roles
Una vez que agregamos ese campo de rol al modelo de base de datos, necesitamos actualizar el perfil del usuario. Para hacer esto, debemos realizar dos cambios. Primero, debemos editar nuestro objeto de opciones para extender el perfil y agregar un par de funciones de callback para incluir el rol en el token y la sesión.
export const authOptions: NextAuthOptions = {
session: {
strategy: "jwt",
},
adapter: PrismaAdapter(prisma),
providers: [
Github({
profile(profile: GithubProfile) {
return {
id: profile.id.toString(),
role: profile.role ?? "user",
name: profile.name,
email: profile.email,
image: profile.avatar_url,
}
},
clientId: process.env.GITHUB_ID as string,
clientSecret: process.env.GITHUB_SECRET as string,
}),
],
callbacks: {
jwt({ token, user }) {
if(user) token.role = user.role
return token
},
session({ session, token }) {
session.user.role = token.role
return session
}
}
}
Pero eso no es todo. Si estás utilizando TypeScript, como lo estamos haciendo en el ejemplo de esta demostación, deberás extender los tipos de user, jwt y sesión para incluir el rol. De lo contrario, te encontrarás con errores de tipo. Para extender estos tipos de clases, crea una carpeta ‘types’ en la raíz del proyecto y dentro de ella, agrega un archivo llamado ‘next-auth.d.ts.’ Puedes utilizar cualquier nombre que prefieras, pero debe terminar con ‘*.d.ts.’
import { DefaultSession, DefaultUser } from "next-auth"
import { JWT, DefaultJWT } from "next-auth/jwt"
import { User } from "@auth/core/types"
declare module "next-auth" {
interface Session {
user: {
role: string,
} & DefaultSession
}
interface User extends DefaultUser {
role: string,
}
}
declare module "next-auth/jwt" {
interface JWT extends DefaultJWT {
role: string,
}
}
declare module "@auth/core/types" {
interface User {
role: string,
}
}
Finalmente, podemos utilizar el rol recién agregado para definir reglas de acceso en nuestras páginas, componentes y middleware. Podemos utilizar getServerSession o useSession, como se describió anteriormente, para acceder a la sesión en componentes del servidor y del cliente. El rol formará parte de la sesión, lo que nos permitirá aplicar cualquier lógica necesaria para permitir o restringir el acceso.
'use client'
import { useSession } from "next-auth/react"
export default function Page() {
const session = await useSession()
if (session?.user.role === "admin") {
return <p>You are an admin</p>
}
return <p>You are not an admin</p>
}
Para controlar el acceso a las páginas, podemos utilizar middleware. El siguiente ejemplo solo permite el acceso a todas las páginas bajo /admin/ a usuarios con el rol “admin”. Los usuarios que no han iniciado sesión serán redirigidos a la página de inicio de sesión, y los usuarios con cualquier otro rol serán redirigidos a una página de acceso denegado. Esto podría ser útil para restringir el acceso a una sección de administración de una aplicación.
import { withAuth } from "next-auth/middleware"
import { NextResponse } from "next/server"
export default withAuth(
function middleware(req) {
if (req.nextauth.token?.role !== "admin") {
return NextResponse.rewrite(
new URL("/denied", req.url)
)
}
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
)
export const config = { matcher: ["/admin"] }
Conclusión
Next.js no proporciona autenticación y control de acceso nativos. Sin embargo, con NextAuth.js, implementar autenticación en un sitio web de Next.js se convierte en un proceso bastante sencillo.
Un aspecto que no hemos cubierto en este artículo es la gestión de usuarios. Necesitarás implementar un mecanismo para asignar el rol correcto a los usuarios. Además, para esta demostración, busqué mantener las cosas simples. Sin embargo, si tu aplicación requiere un control de acceso más granular, podrías considerar desarrollar tu propio sistema de gestión de roles y permisos, y utilizar bibliotecas como CASL.
Vale la pena señalar que, como se mencionó al principio de este artículo, NextAuth.js está actualmente en un proceso de rebranding y transición a Auth.js. Al momento de escribir este artículo, la versión 5 de Auth.js estaba en beta, introduciendo muchos cambios que podrían simplificar aspectos cubiertos aquí. Planeo proporcionar una actualización a este artículo una vez que Auth.js v5 sea lanzado oficialmente.