Passwordless Magic Link Authentication Setup in Astro.js

Published On

In this step by step guide we are going to implement magic link (passwordless) authentication via email with email verification, authentication persistence with Astro.js SSR and API routes.

Here is the demonstration of what we are going to build

This is the part 3 of the 4 part series of implementing Authentication in Astro. In first part I have implemented Google and Github authentication in Astro.js without any external library.

In the second part I have implement Email Password Credentials authentication in Astro.js. This is the third part.

In final part I will implement multi factor auth using app like microsoft authenticator. All the post of the series can be found by following this link.

What are we going to achieve?

So let’s understand and clear the expectation first that after following this what you will be able to achieve.

  • Set up Magic link based Authentication
  • Persist user login for 14 days using database session
  • Protect pages
  • Protect routes

Tools Used

  • Astro.js
  • Solid.js for Form UI
  • Zod for validation
  • Upstash Redis for OTP codes
  • Resend for sending mail

Setup

Download the starter code. You can also clone the repo and select the magic-link-starter branch. It does contain the github, google and email auth already setup with components and utility functions.

Download it and extract it and npm install command to install. Also make sure to set up DB, create an account on Resend and Upstash

Please note that I am continuing with what was left in previous post so in this post I am not setting up DB and all. Please provide feedback if you want to it to be a standalone post.

So let’s start

Create a magic link page. Create a magic-link folder in the pages folder and create a new index.astro file under it.

src/pages/magic-link/index.astro
---
import MainLayout from "../../layout/main-layout.astro";
import MagicLinkForm from "../../components/magic-link-form";
---

<MainLayout title="Magic Link" description="Log in via magic link">
  <MagicLinkForm client:load />
</MainLayout>

The magic link form calls the magic-link api so let’s create that api route.

The API route will generate the verification code and send the mail. Along with sending the mail it will handle rate limiting. We have following constraint in place:

  • Verification link only valid for 2 hour
  • User can only send 1 mail every 10 minutes
  • User can send total of 5 mail in 24 hr

Let’s define the route first and let’s extract sending mail into lib/auth folder.

src/pages/api/magic-link.ts
import type { APIContext } from "astro";
import { and, eq } from "drizzle-orm";
import { db } from "../../../db";
import { users } from "../../../db/schema";
import { sendMagicLink } from "../../../lib/auth";
import EmailSchema from "../../../validations/email";

export async function POST({ request, url }: APIContext) {
  const { email }: { email: string } = await request.json();

  const parsedData = EmailSchema.safeParse(email);

  if (!parsedData.success) {
    return Response.json(
      {
        error: "validation_error",
        message: parsedData.error.format(),
      },
      { status: 400 }
    );
  }

  const userExists = await db.query.users.findFirst({
    where: eq(users.email, parsedData.data),
  });

  if (userExists) {
    return Response.json(
      {
        error: "existing_user",
        message: "User with this email already exists",
      },
      { status: 400 }
    );
  }

  try {
    const res = await sendMagicLink({
      email: parsedData.data,
      url: url.origin,
    });

    if (res.emailSendLimit) {
      return Response.json(
        {
          error: "rate_limit",
          message: `Please wait for 24 hrs before sending new mail request`,
        },
        { status: 429 }
      );
    } else if (res.verificationId) {
      return Response.json(
        { data: { verificationId: res.verificationId } },
        { status: 200 }
      );
    } else if (res.waitTime) {
      return Response.json(
        {
          error: "resend_limit",
          message: `Please wait for ${res.waitTime} minutes before generating new request for mail`,
        },
        { status: 429 }
      );
    }
    return Response.json({ error: "server_error" }, { status: 500 });
  } catch (err) {
    console.log("Error while sending mail", err);
    return Response.json({ error: "server_error" }, { status: 500 });
  }
}

Now let’s define the sendMagicLink function in the lib/auth.ts file

src/lib/auth.ts
export const sendMagicLink = async ({
  email,
  url,
}: {
  email: string;
  url: string;
}) => {
  const verificationId = generateVerificationId();

  try {
    const lastEmailSentTime: number | null = await redis.get(
      `${email}:ml_sent`
    );

    if (lastEmailSentTime) {
      return {
        waitTime:
          10 - Math.floor((new Date().getTime() - lastEmailSentTime) / 60000),
      };
    }

    const emailSentCount: number | null = await redis.get(`${email}:ml_count`);

    if (emailSentCount == null || emailSentCount > 0) {
      const res = await fetch("https://api.resend.com/emails", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${import.meta.env.RESEND_API_KEY}`,
        },
        body: JSON.stringify({
          from: import.meta.env.FROM_EMAIL,
          to: email,
          subject: `Log in to Astro Auth`,
          html: `<div>Log in as ${email} </div>
          <a href="${url}/magic-link/${verificationId}">Log in</a>
          <div>The link is valid for 2 hours</div>
          <div>You have received this email because you or someone tried to signup on the website </div>
          <div>If you didn't signup, kindly ignore this email.</div>
          <div>For support contact us at contact[at]example.com</div>
          `,
        }),
      });

      if (res.ok) {
        const verificationIdPromise = redis.set(verificationId, email, {
          ex: 7200,
        });

        let emailCountPromise;

        if (emailSentCount === null) {
          emailCountPromise = redis.set(`${email}:ml_count`, 4, {
            ex: 86400,
          });
        } else {
          emailCountPromise = redis.decr(`${email}:ml_count`);
        }

        const emailSentPromise = redis.set(
          `${email}:ml_sent`,
          new Date().getTime(),
          {
            ex: 600,
          }
        );

        const [res1, res2, res3] = await Promise.all([
          verificationIdPromise,
          emailCountPromise,
          emailSentPromise,
        ]);

        if (res1 && res2 && res3) {
          return { verificationId };
        } else {
          throw new Error("Error while sending mail");
        }
      } else {
        throw new Error("Error while sending mail");
      }
    } else {
      return { emailSendLimit: true };
    }
  } catch (error) {
    console.log("error while sending mail", error);
    throw new Error("Error while sending mail");
  }
};

Now the email will send the user link for example: [http://localhost:4321/magic-link/subPyQxxOKzsm9irgkXNDAOo]. Let’s define the new page under magic-link which will check the ID and create user.

The flow for this page will be

  • Check if link is valid
  • If not valid then show error message
  • If valid check user exist
  • If user doesn’t exist user is signing up. So create user, create session, logs and redirect user to profile page
  • If user exists then user is logging in. Create session, logs and redirect user to dashboard

Let’s define this in the page

src/pages/magic-link/[verificationId].astro
---
import { eq } from "drizzle-orm";
import { db } from "../../db/index";
import { users } from "../../db/schema";
import MainLayout from "../../layout/main-layout.astro";
import { createLoginLog, createSession } from "../../lib/auth";
import redis from "../../lib/redis";
import EmailSchema from "../../validations/email";
let isBlocked = false;
let serverError = false;
let error = false;
let success = false;
const validLink = await redis.get(Astro.params.verificationId!);

if (!validLink) {
  error = true;
} else {
  const parsedData = EmailSchema.safeParse(validLink);

  if (!parsedData.success) {
    error = true;
    return;
  }
  success = true;

  try {
    const userExists = await db.query.users.findFirst({
      where: eq(users.email, parsedData.data),
      columns: { id: true, isBlocked: true },
    });

    if (userExists) {
      const { sessionId, expiresAt } = await createSession({
        userId: userExists.id,
      });

      await createLoginLog({
        sessionId,
        userAgent: Astro.request.headers.get("user-agent"),
        userId: userExists.id,
        ip: Astro.clientAddress ?? "dev",
      });

      Astro.cookies.set("app_auth_token", sessionId, {
        path: "/",
        httpOnly: true,
        sameSite: "lax",
        expires: expiresAt,
        secure: import.meta.env.PROD,
      });

      await redis.del(Astro.params.verificationId!);

      return Astro.redirect("/dashboard");
    } else {
      const newUser = await db
        .insert(users)
        .values({
          email: validLink as string,
          emailVerified: true,
        })
        .returning({ id: users.id });

      const { sessionId, expiresAt } = await createSession({
        userId: newUser[0].id,
      });

      await createLoginLog({
        sessionId,
        userAgent: Astro.request.headers.get("user-agent"),
        userId: newUser[0].id,
        ip: Astro.clientAddress ?? "dev",
      });
      await redis.del(Astro.params.verificationId!);
      Astro.cookies.set("app_auth_token", sessionId, {
        path: "/",
        httpOnly: true,
        sameSite: "lax",
        expires: expiresAt,
        secure: import.meta.env.PROD,
      });
      Astro.redirect("/profile");
    }
  } catch (error) {
    console.log("Error in verifying magic link", error);
    serverError = true;
  }
}
---

<MainLayout title="Magic Link" description="Log in via magic link">
  {
    error && (
      <div class="my-10 flex items-center justify-center flex-col">
        <h2 class="text-3xl text-center font-bold">Invalid Link</h2>

        <p class="my-2">
          The link is invalid or expired. Please re-generate a new link.
        </p>
        <a
          href="/magic-link"
          class="px-5 py-2 my-5 bg-blue-600 text-white rounded-md"
        >
          Generate New Link
        </a>
      </div>
    )
  }

  {
    success && (
      <div class="my-10 flex items-center justify-center">
        <h2 class="text-3xl text-center font-bold">Login Success</h2>
        <p class="my-2">Redirecting...</p>
      </div>
    )
  }
  {
    isBlocked && (
      <div class="my-10 flex items-center justify-center">
        <h2 class="text-3xl text-center font-bold">Access Restricted</h2>
        <p class="my-2">Please contact support</p>
      </div>
    )
  }
  {
    serverError && (
      <div class="my-10 flex items-center justify-center">
        <h2 class="text-3xl text-center font-bold">Server Error</h2>
        <p class="my-2">Please try again later.</p>
      </div>
    )
  }
</MainLayout>

This will create the user if the link is valid. Also this will persist the user for 14 days.

Protecting route via middlewares

src/middleware.ts
import { defineMiddleware } from "astro/middleware";
import getUser from "./lib/getUser";

export const onRequest = defineMiddleware(async (context, next) => {
  const userInfo = await getUser(context.cookies.get("app_auth_token")?.value);

  context.locals.userId = userInfo?.user?.id;

  if (
    context.url.pathname.includes("dashboard") ||
    context.url.pathname.includes("account")
  ) {
    if (!userInfo || !userInfo.user) {
      return context.redirect("/login");
    } else {
      return next();
    }
  }

  if (context.url.pathname.includes("login")) {
    if (userInfo?.user) {
      return context.redirect("/");
    }
  }

  return next();
});

Conclusion

So in this post I implemented the magic link based authentication. The completed code can be found in the repo under the magic-link-final branch.

This the 3rd part of the Astro Authentication Series. In the next part I will implement two factor authentication. Join the EverythingCS discord server if you have any further query and for future post updates.