How to implement Magic Link Authentication with Next.js and Altogic

How to implement Magic Link Authentication with Next.js and Altogic

This article will cover magic-link authentication basics using Next.js and Altogic, a backend-as-a-service platform using its client library.

How to implement Magic Link Authentication with Next.js and Altogic

Introduction

This article will cover magic-link authentication basics using Next.js and Altogic, a backend-as-a-service platform using its client library. This authentication method allows users to sign in to the application without remembering their password.

To reduce UX friction and avoid remembering multiple passwords, some small/medium and even large organizations are moving out from the password-based authentication flow to magic authentication, depending on their risk appetite.

You can check out the source code and demo app.

Benefits

With the magic link authentication method, the user does not have to remember another password or enter it to access their account. So we can clearly understand that magic link authentication highly simplifies the login burden for users and provides a better user experience.

Disadvantages

For that authentication method, the primary condition is that the link needs to be safe and can not be able to manipulated from outside of the application. And the links should have to be used for just a few minutes and only once. So except for these conditions, a passwordless authentication seems safer than one with a password.

💡 If you know any other disadvantages that you discover or faced before, please write in the comments section, we would love to discuss and learn.

  1. Users who already have an account enter their email and click the “Send magic link” button.

  2. An email with the magic link is sent to the specified email address by Altogic.

  3. Users click on the link in the sent email.

  4. Altogic redirects users to specified “Redirect URL” with an access token in the query string parameter.

  5. This access token is used to get a session token, and users are directed to their profile page.

Youtube Promo Video

You can check out the video below to see a live demonstration of our app.

Set up the project

This project builds on top of the complete e-mail authentication app we created previously. You can follow the tutorial to build the same app or clone the project from here and continue with the rest of the tutorial.

Let’s start coding!

We already have the backend and frontend of the email authentication now. We can start implementing the magic link functionality.

We need a page where we get the email input from the user.

Using the “altogic.auth.sendMagicLinkEmail(email)” we can send magic link mail to the specified email.

Here is the source code of the /auth/send-magic-link page:

import { useState, useRef, useEffect } from "react";
import { altogic } from "../../helpers/client";
import ErrorMessage from "../../components/ui/ErrorMessage";
import CustomButton from "../../components/ui/Button";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEnvelopeCircleCheck } from "@fortawesome/free-solid-svg-icons";
import { checkCookies } from "cookies-next";

function SendMagicLink() {
  const emailInputElement = useRef();
  const [message, setMessage] = useState("");
  const [loading, setLoading] = useState(false);
  const [emailSent, setEmailSent] = useState(false);

  async function sendMagicLinkHandler() {
    setLoading(true);
    try {
      let email = emailInputElement.current.value;
      // Send the magic link email to specified email.
      const { errors } = await altogic.auth.sendMagicLinkEmail(email);
      if (errors) {
        setMessage(errors.items[0].message);
      } else {
        setEmailSent(true);
        setMessage("");
      }
    } catch (error) {
      setMessage(error.message.toString());
    }
    setLoading(false);
  }

  return (
    <div className=" min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 ">
      <div className="max-w-md w-full space-y-8">
        <div className="my-6 ">
          {emailSent ? (
            <>
              <FontAwesomeIcon
                className="flex items-center justify-center mx-auto"
                icon={faEnvelopeCircleCheck}
                size={"5x"}
                color="green"
              />
              <p className="text-xl text-center font-semibold leading-6 ">
                Magic link is sent to your email. You need to click the link to
                sign-in.
              </p>
            </>
          ) : (
            <div className="mt-6">
              <label
                htmlFor="email"
                className="block text-sm font-medium text-slate-700"
              >
                Your email
              </label>
              <div className="mt-1">
                <input
                  ref={emailInputElement}
                  type="email"
                  name="email"
                  id="email"
                  placeholder="example@example.com"
                  className="px-3 py-2 bg-white border shadow-sm border-slate-300 placeholder-slate-400 disabled:bg-slate-50 disabled:text-slate-500 disabled:border-slate-200 focus:outline-none focus:border-indigo-500 focus:ring-indigo-500  w-full rounded-md sm:text-sm focus:ring-1 invalid:border-red-500 invalid:text-red-600 focus:invalid:border-red-500 focus:invalid:ring-red-500 disabled:shadow-none"
                />
              </div>
              <div className="mt-6 text-center">
                <ErrorMessage message={message}></ErrorMessage>
                <CustomButton
                  loading={loading}
                  buttonValue={"Send the magic link"}
                  onClick={sendMagicLinkHandler}
                />
              </div>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export async function getServerSideProps({ req, res }) {
  // If user is already signed in, redirect them to profile.
  if (checkCookies("token", { req, res })) {
    return {
      redirect: { destination: "/profile", permanent: false },
    };
  } else {
    return {
      props: {},
    };
  }
}

export default SendMagicLink;

In the end, your screen should look like this:

Altogic send magic link screen

Default magic link email Altogic sends

You can also change all of the message templates from the App settings → Authentication → Message templates view of Altogic Designer and use any HTML template you want.

Here is how to do that:

Change message templates in Altogic Designer

Now that we sent the email, users need to click on the link to sign in.

Redirect URL route

When users click on the link, Altogic redirects to our specified Redirect URL, which is /auth-redirect in this case.

Here in getServerSideProps, we check the query string parameters, and according to each action, we perform some actions. Here is an important part!

getAuthGrant() function either takes a session token as a parameter or uses the one in the URL.

Here, we run this code only on the server-side, so we must give the session token as the parameter.

import { useRouter } from "next/router";
import React from "react";
import { useEffect, useState, useContext } from "react";
import EmailConfirmResend from "../components/auth-redirect/EmailConfirmResend";
import MagicLinkResend from "../components/auth-redirect/MagicLinkResend";
import PasswordReset from "../components/auth-redirect/PasswordReset";
import { UserContext } from "../context/userContext";
import { altogic } from "../helpers/client";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
import { checkCookies } from "cookies-next";

function AuthRedirect(props) {
  const [isLoading, setIsLoading] = useState(true);
  const [isResetScreen, setIsResetScreen] = useState(false);
  const [isMagicLinkScreen, setIsMagicLinkScreen] = useState(false);
  const router = useRouter();
  const context = useContext(UserContext);

  async function setNewUser() {
    //Set the user's new data in context and in local storage.
    const activeSession = altogic.auth.getSession();
    if (activeSession) {
      const newUserData = await altogic.auth.getUserFromDB();
      context.authStateChanged(activeSession, newUserData.user);
      router.push("/profile");
    } else {
      router.push("/auth/email-change-confirm");
    }
  }
  async function checkProps() {
    if (props.action == "email-confirm") {
      if (props.error) {
        //There is no valid token.
        setIsLoading(false);
      } else {
        //Send a request to /api/login route to set the cookie for SSR.
        //Only needed if you are going to do SSR.
        await fetch("/api/login", {
          method: "POST",
          credentials: "include",
          body: JSON.stringify({ token: props.session.token }),
          headers: { "Content-Type": "application/json" },
        });
        context.setIsAuth(true);
        //Email has been verified on the server side.
        context.authStateChanged(props.session, props.user);
        router.push("/profile");
      }
    } else if (props.action === "reset-pwd") {
      //Show the reset password screen.
      setIsResetScreen(true);
      setIsLoading(false);
    } else if (props.action === "change-email") {
      //Change email is successfull.
      setNewUser();
      return;
    } else if (props.action === "magic-link") {
      if (props.error) {
        setIsLoading(false);
        // Resend the magic link screen.
        setIsMagicLinkScreen(true);
      } else {
        //Send a request to /api/login route to set the cookie for SSR.
        //Only needed if you are going to do SSR.
        await fetch("/api/login", {
          method: "POST",
          credentials: "include",
          body: JSON.stringify({ token: props.session.token }),
          headers: { "Content-Type": "application/json" },
        });
        context.setIsAuth(true);
        context.authStateChanged(props.session, props.user);
        router.push("/profile");
      }
    }
  }

  useEffect(() => {
    checkProps();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return isLoading ? (
    //Email change confirm page. It confirms automatically.
    <FontAwesomeIcon
      className="flex items-center justify-center w-full max-w-md mx-auto my-20 overflow-hidden"
      icon={faSpinner}
      size={"3x"}
      spin
    />
  ) : (
    <div className=" min-h-full flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 ">
      <div className="max-w-md w-full space-y-8">
        {isMagicLinkScreen ? (
          //Magic link didn't work. Resending the magic link screen.
          <MagicLinkResend error={props.error}></MagicLinkResend>
        ) : !isResetScreen ? (
          //Email confirm page. If it can't confirm automatically, it shows a form where you can resend the email.
          <EmailConfirmResend error={props.error}></EmailConfirmResend>
        ) : (
          <PasswordReset
            access_token={props.access_token}
            error={props.error}
          ></PasswordReset>
        )}
      </div>
    </div>
  );
}

export async function getServerSideProps(context) {
  let req = context.req;
  let res = context.res;

  // context.query returns the query parameters in the url.
  // When Altogic redirects to auth-redirect, it also sends some values as query parameters.
  if (context.query.action === "reset-pwd") {
    if (context.query.status === "200") {
      //Token is valid, you can reset the password.
      return {
        props: {
          action: context.query.action,
          error: null,
          access_token: context.query.access_token,
        },
      };
    } else {
      return {
        props: {
          action: context.query.action,
          error: context.query.error,
        },
      };
    }
  } else if (context.query.action === "change-email") {
    return {
      props: {
        action: context.query.action,
        error: null,
      },
    };
  } else if (context.query.action === "magic-link") {
    if (checkCookies("token", { req, res })) {
      // Check if there is a token between the browser and the server.
      // Redirect to the profile page and check there if token belongs to this user or not.
      return {
        redirect: { destination: "/profile", permanent: false },
      };
    }
    if (context.query.status === "400") {
      //If token is invalid, return the error message in the URL.
      return {
        props: {
          action: context.query.action,
          error: context.query.error,
        },
      };
    }
    try {
      const { user, session, errors } = await altogic.auth.getAuthGrant(
        context.query.access_token
      );
      if (session) {
        return {
          props: {
            action: context.query.action,
            error: null,
            user: user,
            session: session,
          },
        };
      } else {
        return {
          props: {
            action: context.query.action,
            error: errors.items[0].message,
          },
        };
      }
    } catch (error) {
      return {
        props: {
          action: context.query.action,
          error: error.message,
        },
      };
    }
  } else if (context.query.action === "email-confirm") {
    let response;
    if (context.query.status === "200") {
      //We get the session and the user from the token using getAuthGrant()
      response = await altogic.auth.getAuthGrant(context.query.access_token);
      if (response.user) {
        // Successfully verified the email.
        return {
          props: {
            action: context.query.action,
            error: null,
            user: response.user,
            session: response.session,
          },
        };
      } else {
        return {
          props: {
            action: context.query.action,
            error: response.errors.items[0].message,
          },
        };
      }
    } else {
      //Email couldn't be verified.
      return {
        props: { action: context.query.action, error: context.query.error },
      };
    }
  } else {
    return { props: {} };
  }
}

export default AuthRedirect;

Magic links are one-time links. If users click on the link a second time or after it expires, they get an error. This feature makes the magic link authentication method more secure.

Magic link url when you use it again.

As we can see from the URL, the access token is already used or invalid.

💡 You can directly insert the error message from the link for convenience.

Conclusion

This article added the magic link authentication method to our email authentication app using Next.js and Altogic Client Library. Thanks to Altogic, we can build this functionality with just a few lines of code.

You can check out the GitHub repository for other functionalities and the rest of the code. You can also clone it and build your app on top of it.