Saltar al contenido principal

AuthJS

DB

  1. Tener una base de datos disponible, como Astro DB (db) con Turso
  2. Configurar y relacionar tablas si es necesario, como Roles y Users

1. AuthJS

  • astro/client para tener información del lado del cliente
  • astro/server para sesiones del lado del server

1. Instalar

bun astro add auth-astro

2. Variables

El AUTH_SECRET debe tener un mínimo de 32 caracteres

.env
# AuthJS
AUTH_TRUST_HOST=true # Si no se despliega en Vercel
AUTH_SECRET=<min-32-characters>
#GITHUB_CLIENT_ID=abc
#GITHUB_CLIENT_SECRET=abc
}),

3. Archivo de configuración

  • Crear archivo de configuración en el root del proyecto (en /, no /src)
  • Se configuran los métodos de autenticación y, si es necesario, en los callbacks definir la información personalizada de user del objeto session
Nota

Si se utiliza credentials (correo y contraseña) entonces se debe crear antes el proceso de registro para poder validar después el correo y la contraseña.

auth.config.ts
import type { AdapterUser } from "@auth/core/adapters";
import Credentials from "@auth/core/providers/credentials";
import { db, eq, User } from "astro:db";
import { defineConfig } from "auth-astro";
import bcrypt from "bcryptjs";

export default defineConfig({
  providers: [
    // Email and password provider
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      authorize: async ({ email, password }) => {
        // Check if email and password are correct
        const [user] = await db
          .select()
          .from(User)
          .where(eq(User.email, `${email}`));

        if (!user) throw new Error("Invalid email or password");
        if (!bcrypt.compareSync(password as string, user.password)) {
          throw new Error("Invalid email or password");
        }

        // Return object with user session data
        // Add custom info (auth.d.ts)
        const { password: _, ...rest } = user;
        return rest;
      },
    }),
  ],

  callbacks: {
    // The user data is the same as the Credentials.authorize return
    // The data in token es the data returned
    jwt: ({ token, user }) => {
      // Add user to token for session can access it
      if (user) token.user = user;
      return token;
    },
    session: ({ session, token }) => {
      // Add user data to session
      session.user = token.user as AdapterUser;
      return session;
    },
  },
});
  • Por defecto, solo devuelve el email del usuario que inició sesión, este se puede extraer de session.user.email
  • Al devolver un objeto con los datos del usuario, ej. return user, estos se devuelven en el payload del token pero estos datos no se pueden ver o extraer fácilmente (como se haría con el email)
  • Para extraer los datos personalizados, se debe especificar en los callbacks y ya estará disponible en el objeto de session, para tener tipado estricto se debe agregar auth.d.ts
import { getSession } from "auth-astro/server";

const session = await getSession(Astro.request);

4. Op. Archivo de definición

  • Archivo de definición si se agrega información extra a la sesión y se requiere el tipado estricto
  • Si no se agrega más información a la sesión, únicamente estará disponible el email del usuario en session.user
  • Si se agrega información, entonces únicamente estará disponible esa información personalizada y el ID (no el email)

Ejemplo agregando solo el rol

auth.d.ts
import { DefaultUser } from "@auth/core/types";

declare module "@auth/core/types" {
  interface User extends DefaultUser {
    role: string;
  }

  interface Session extends DefaultSession {
    user: User;
  }
}

Ejemplo agregando más campos

auth.d.ts
import { DefaultUser } from "@auth/core/types";

declare module "@auth/core/types" {
  interface User extends DefaultUser {
    role?: string;
    customFieldOne?: boolean;
    customFieldTwo?: string;
  }

  interface Session extends DefaultSession {
    user: User;
  }
}

2. Op. Register

Action

src\actions\auth\register.action.ts
import { defineAction } from "astro:actions";
import { db, eq, User } from "astro:db";
import { z } from "astro:schema";
import bcrypt from "bcryptjs";
import { v4 as UUID } from "uuid";

export const register = defineAction({
  accept: "form",
  input: z.object({
    name: z.string().min(2).max(50),
    email: z.string().email(),
    password: z.string().min(6).max(50),
  }),
  handler: async ({ name, email, password }) => {
    // Check if email is already registered
    const [user] = await db.select().from(User).where(eq(User.email, email));
    if (user) throw new Error("Email already registered");

    // Create user
    await db.insert(User).values({
      id: UUID(),
      name,
      email,
      password: bcrypt.hashSync(password),
      role: "user",
    });

    return true;
  },
});

Form

src\pages\auth\register.astro
<form>
<!-- Labels and inputs -->

<button id="btn-submit" class="w-full disabled:bg-primary/50">
Register
</button>
</form>

<script>
  import { actions } from "astro:actions";

  // Get form and submit button
  const form = document.querySelector("form") as HTMLFormElement;
  const btnSubmit = document.getElementById("btn-submit") as HTMLButtonElement;

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    btnSubmit.setAttribute("disabled", "disabled");

    // Register user
    const formData = new FormData(form);
    const { error } = await actions.register(formData);

    if (error) {
      alert(error.message);
      btnSubmit.removeAttribute("disabled");
      return;
    }

    // If no error, redirect to login page
    window.location.replace("/auth/login");
  });
</script>

3. Login

  • Client side
  • La importación del sign in permite iniciar sesión con cualquier proveedor
  • No es necesario definir un action porque se está utilizando la configuración de auth.config.ts
src\pages\auth\login.astro
<form action="/login" method="POST">
<!-- Data... -->

<button id="btn-submit" class="w-full disabled:bg-primary/50">
Login
</button>
</form>

<script>
  const { signIn } = await import("auth-astro/client");

  const form = document.querySelector("form") as HTMLFormElement;
  const btnSubmit = document.getElementById("btn-submit") as HTMLButtonElement;

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    btnSubmit.setAttribute("disabled", "disabled");

    const formData = new FormData(form);
   
    const res = await signIn("credentials", {
      email: formData.get("email") as string,
      password: formData.get("password") as string,
      redirect: false,
    });

    if (res) {
      alert("Email or password is incorrect");
      btnSubmit.removeAttribute("disabled");
      return;
    }

    window.location.replace("/dashboard");
  });
</script>

4. Logout

src\components\shared\Navbar.astro
<li id="logout">Logout</li>

<script>
  const { signOut } = await import("auth-astro/client");

  const logoutElem = document.getElementById("logout") as HTMLLIElement;
  logoutElem?.addEventListener("click", async () => {
    await signOut();
    window.location.href = "/auth/login";
  });
</script>

5. Middleware

Se extrae la información del usuario de session (AuthJS) como roles, nombre, email, etc.. Si no viene toda la información que se necesita (ej. isAdmin, removed) entonces se configura el return en el archivo de configuración auth.config.ts

El middleware también permite configurar y compartir información específica de la solicitud entre puntos finales y páginas mediante el objeto de Astro.locals que está disponible en todos los componentes de Astro y puntos finales de API.

Entonces, con los datos extraídos de session se pueden asignar los campos de Astro.locals y así utilizar Astro.locals para extraer la información del usuario en cada página o endpoint y no tener que utilizar session otra vez, únicamente en el middleware.

src\middleware.ts
import { defineMiddleware } from "astro:middleware";
import { getSession } from "auth-astro/server";

const publicRoutes = ["/auth/login", "/auth/register"];

export const onRequest = defineMiddleware(
  async ({ url, locals, redirect, request }, next) => {
 
    // Verify if user is logged in
    const session = await getSession(request);
    const isLoggedIn = !!session;
    const user = session?.user;

    // Set values to Astro Locals
    locals.isLoggedIn = isLoggedIn;
    locals.user = null;
    locals.isAdmin = false;
    if (user) {
      locals.user = {
        name: user.name!,
        email: user.email!,
      };
      locals.isAdmin = user.role === "admin";
    }

    // Protect the login and register routes when user is logged in
    if (isLoggedIn && publicRoutes.includes(url.pathname)) {
      return redirect("/dashboard");
    }
   
    // Redirect to login if user is not logged in
    if (!isLoggedIn && url.pathname.startsWith("/dashboard")) {
      return redirect("/auth/login");
    }

    return next();
  }
);

env.d.ts

  • env.d.ts se utiliza para agregar campos personalizados a Astro.locals
src\env.d.ts
interface User {
  name: string;
  email: string;
}

declare namespace App {
  interface Locals {
    isLoggedIn: boolean;
    isAdmin: boolean;
    user: User | null;
  }
}

Ej. Extrayendo la información del usuario de Astro.locals

src\components\shared\Navbar.astro
---
const { isLoggedIn, isAdmin } = Astro.locals;
---

<nav>
<ul>
<li><a href="/">Home</a></li>
    {
    isAdmin && (
      <li><a href="/admin/dashboard">Admin</a></li>
      )
    }
    {
    !isLoggedIn ? (
      <li><a href="/auth/login">Ingresar</a></li>
      ) : (
      <li id="logout"><a href="#">Salir</a></li>
      )
    }
  </ul>
</nav>

Ej. Extrayendo la información del usuario de session

src\pages\index.astro
---
import MainLayout from "@/layouts/MainLayout.astro";
import { getSession } from "auth-astro/server";

// Get session without middleware and Astro.locals
const session = await getSession(Astro.request);
const { user } = session ?? {};
---

<pre>
<code>
  { JSON.stringify(user) }
  </code>
</pre>