El conocimiento es el nuevo dinero.
Aprender es la nueva manera en la que inviertes
Acceso Cursos

Autenticación para Next.js 13 mediante Auth.js (enrutador de aplicaciones)

· 5 min de lectura
Autenticación para Next.js 13 mediante Auth.js (enrutador de aplicaciones)

Este es un tutorial que le mostrará cómo puede utilizar Auth.js para la autenticación de su aplicación Next.js. En este tutorial sólo utilizaré componentes de servidor. También voy a utilizar el método de "credenciales" para la autenticación.

Antes de empezar, mencionaré que utilizo los siguientes paquetes

npm i next-auth para autenticación

npm i bcrypt para la generación de hash y verificación de contraseñas

npm i zod para facilitar la validación de datos

npm i mongodb para acceso a base de datos (MongoDB)

Middleware


Digamos que tenemos un proyecto con una página /admin y queremos que los usuarios inicien sesión introduciendo un nombre de usuario y una contraseña.

Queremos redirigir a los usuarios no autorizados a una página /login. Podemos hacer esto usando el middleware Next.js.

// /middleware.ts
import { User } from "next-auth";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";

export const middleware = async( request: NextRequest ) => {

  const session:session = await fetch(`${process.env.serverURL}/api/auth/session`, {
    headers:headers(),
    // cache: "no-store"
  })
  .then( async( res ) => await res.json() );

  const loggedIn = Object.keys(session).length > 0? true :false;
  const pathname = request.nextUrl.pathname;

  if ( pathname != "/admin/login" && !loggedIn ){
    return NextResponse.redirect( new URL( '/admin/login', process.env.serverURL ) );
  }

}

export const config = {
  matcher : ["/admin/:path*"]
}

type session = {} | User;

Nota: middleware.ts debe estar fuera de la carpeta /app.

EDIT: Al enviar formularios el middleware anterior puede dar errores.

Internal error: Error: Unexpected end of form
or 
fetch failed

Hay 2 maneras de solucionar este problema:

  1. Utilizar sólo las cabeceras necesarias
{ //inside middleware()
  ...
  const headersList = headers();
  const authHeader = headersList.get('authorization');
  const cookieHeader = headersList.get('cookie');
  
  const session = await fetch(url, {
        headers: {
          // "authorization": authHeader, //might not be needed
          "cookie": cookieHeader
        },
  ...
}

2. Llamar al middleware sólo en peticiones 'GET' ( no recomendado ).

{
...
  if ( request.method === "GET" ){
    // your code
  }
}
Internal error: Error: Unexpected end of form
or 
fetch failed

La página de inicio de sesión


Para que Auth.js funcione necesitamos proporcionar un csrfToken junto con nuestro formulario.

Su página de inicio de sesión debe ser algo como esto:

// /app/admin/login/page.tsx
import { headers } from "next/headers";


const LoginPage = async( { searchParams }:{ searchParams: { error?: string } } ) => {


  const csrfToken = await fetch(`${process.env.serverURL}/api/auth/csrf`,{
    headers: headers(),
  })
    .then( res => res.json() )
    .then( csrfTokenObject => csrfTokenObject?.csrfToken );

  return (

   //using TailwindCSS classes btw.
    <main className="flex flex-col items-center mt-2">

      <form 
        method="POST"
        action={`${process.env.serverURL}/api/auth/callback/credentials`} 
        className="flex flex-col group gap-2">
      
        <input 
          className="outline-none focus:border-b border-black" 
          required 
          placeholder="login" 
          name="login"/>

        <input 
          className="outline-none focus:border-b border-black" 
          required 
          placeholder="password" 
          name="password" 
          type="password"/>

        <input 
          hidden 
          value={csrfToken} 
          name="csrfToken" 
          readOnly/>

        <button 
          className="outline-none 
            focus:underline focus:decoration-red-600 
            focus:group-valid:decoration-green-600">
              submit
        </button>
        
      </form>

      {
        searchParams.error && 
          <p 
            className="text-red-600 text-center capitalize">
              login failed.
          </p>
      }

      
    </main>
  )
}


export default LoginPage;

Ten en cuenta que el formulario personalizado no funcionará sin el csrfToken.
Si estuvieras usando el cliente, podrías obtener el token vía await getCsrfToken() dentro de un async() dentro de useEffect().
Sin embargo, quiero mantener las cosas simples, así que lo hice de esta manera.

Verificación de usuarios


Voy a utilizar MongoDB para almacenar las credenciales de administrador. Un documento admin tendrá este aspecto:

{
 _id: ObjectId,
 login: string,
 password: string //hased password (check bcrypt documentation)
}

Para hacer hash y verificar las contraseñas usaremos bcrypt

Nuestra ruta lógica de verificación puede ser algo así:

//e.g. /app/api/verification/route.ts

import { MongoClient, ObjectId } from "mongodb";
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcrypt";
import { z } from "zod";


export const POST = async ( request: NextRequest ) => {


  const client = new MongoClient(process.env.MONGODB_URI!);

  try{

    const credentials:credentials = await request.json();
    const login = z.string()
      .regex( /^[A-Z0-9]+$/gmi, "Invalid username" ) //I only accept alphanumeric chars.
      .parse( credentials.login );
    
    const admins = client.db('myDatabase').collection('admins');

    const mongoResult : adminResult | null = await admins
      .findOne( { login: login } ) as unknown as adminResult | null;

    if (mongoResult){
      
      //Verify password
      const passwordIsCorrect = await bcrypt
        .compare( credentials.password, mongoResult.password );

      if ( passwordIsCorrect ){

        const customUser: customUser = {
          id: mongoResult._id.toString(), //required field!!
          username: login,
        }

        return NextResponse.json( customUser );
      }

    }

    return NextResponse.json( null );

  }
  catch(err) {
    return NextResponse.json( null );
  }
  finally{
    client.close();
  }
}

type credentials = {
  login:string,
  password:string
}

type adminResult = {
  _id : ObjectId,
  login : string,
  password : string
}

export type customUser = {
  id : string,
  username : string,
}

Auth.js


La estructura de archivos por defecto debería ser la siguiente

/app/api/auth/[...nextauth]/ruta.ts

Nota: Puede configurar este /api/auth cambiando su variable NEXTAUTH_URL .env.

e.g.
NEXTAUTH_URL=https://example.com/myapp/api/authentication

// /api/auth/signin -> /myapp/api/authentication/signin
// /app/api/auth/[...nextauth]/route.ts
import { customUser } from "@/app/api/verification"
import NextAuth, { User } from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"

const handler = NextAuth({
  providers: [
    CredentialsProvider({

      //this field is not necessary
      //unless you use the built-in form.
      //However it also gives us our "credentials" type below.

      credentials: {
        login: { label: "username" },
        password: { label: "password", type: "password" }
      },

      //the credentials are passed with our
      //login form.
      async authorize(credentials) {
        
        //verify our credentials using the route
        //we created above
        const user: customUser|null = await fetch ( `${process.env.serverURL}/api/verification`, 
        { 
          method: "POST",
          body: JSON.stringify(credentials),
          cache:"no-cache" //! To be removed after done testing
        })
        .then( async( res ) => { 
          if (res.ok) return await res.json();
          else return null;
        })

        if ( user && user.username) {

          const sessionUser: User = {
            id: user.id,          // required string !!!
            name: user.username,  // undefined | null | string
            email: undefined,     // undefined | null | string
            image: undefined      // undefined | null | string
          }

          return sessionUser;
          
        }

        return null;

      },
    }),
  ],

  //remove this field 
  //if you use the built-in form
  pages: {
    signIn: '/admin/login' //default is /api/auth/signin
    //this will redirect us
    //to our custom login page
    //including an error searchParam
    //if there is an issue.
  }
})

export { handler as POST, handler as GET }

¡¡Algunas cosas a tener en cuenta!!


Errores en la página de inicio de sesión con Vercel


Por alguna razón este código dentro de la página de inicio de sesión:

const csrfToken = await fetch(`${process.env.serverURL}/api/auth/csrf`,{
    headers: headers(),
  })
    .then( res => res.json() )
    .then( csrfTokenObject => csrfTokenObject?.csrfToken );

Lanza un error al desplegar con Vercel.

//browser
Application error: a server-side exception has occurred

//server logs
a [Error]: Headers cannot be modified. Read more: https://nextjs.org/docs/app/api-reference/functions/headers
    at Proxy.callable (/var/task/node_modules/next/dist/compiled/next-server/app-page-experimental.runtime.prod.js:93:45157)
    at ye (/var/task/___vc/__launcher/__launcher.js:14:6533)
    at t.mutateHeaders (/var/task/___vc/__launcher/__launcher.js:14:7944)
    at /var/task/___vc/__launcher/__launcher.js:7:991
    at /var/task/___vc/__launcher/__launcher.js:14:5246
    at _optionalChain (/var/task/___vc/__launcher/__launcher.js:7:969)
    at i (/var/task/___vc/__launcher/__launcher.js:14:5168)
    at i (/var/task/node_modules/next/dist/compiled/next-server/app-page-experimental.runtime.prod.js:117:617)
    at A (/var/task/node_modules/next/dist/compiled/next-server/app-page-experimental.runtime.prod.js:135:66295)
    at /var/task/node_modules/next/dist/compiled/nex

Este error no parece aparecer durante el desarrollo o cuando autoalojo la compilación.

Una solución podría ser obtener el csrfToken mediante cookies().

// /app/admin/login/page.tsx
import { cookies } from "next/headers";

//async not needed here
const LoginPage = ( { searchParams }:{ searchParams: { error?: string } } ) => {

  const csrfToken = cookies() //Might be empty before the first submit
    .getAll()
    .find( cookie => cookie.name == "next-auth.csrf-token")?.value
    .split('|')[0]; 
    //because it returns a string: "cookie1|cookie2"
...

Siempre puedes usar un componente cliente también.

EDIT: Puedes arreglar este error usando headers().get('cookie') de la misma manera que se menciona en un 'EDIT' más arriba ( revisa la sección middleware ).

Una última cosa a tener en cuenta:
Inicialmente estaba usando la cookie de sesión para comprobar si el usuario había iniciado sesión. Sin embargo eso era una vulnerabilidad de seguridad.

Nunca hagas algo como esto:

// /middleware.ts
import { cookies } from "next/headers"
import { NextResponse } from "next/server";

export const middleware = ( ) => {

  const authCookie = cookies().get('next-auth.session-token');

  if ( !authCookie || !authCookie.value ){
    return NextResponse.redirect(`${process.env.serverURL}/admin/login`);
  }

//this approach is a security vulnerability
//as anyone can bypass this redirect by
//faking the cookie
//e.g. type in the browser console:
//document.cookie = "next-auth.session-token=[randomString]";
}

export const config = {
  matcher : ["/admin/:path"]
}

Espero que este post haya ayudado a alguien.

Plataforma de cursos gratis sobre programación