nextauthjs / next-auth

Authentication for the Web.
https://authjs.dev
ISC License
24.68k stars 3.47k forks source link

Any ideas how to auto log in email (magic link) provider as a one off bypass for the email click? #8077

Closed jasondainter closed 9 months ago

jasondainter commented 1 year ago

Question šŸ’¬

Hi All.

Does anyone know if it is possible, or even better have any code references of a nextauth set up using Email Provider (aka the one sending magic links to users to click to login) to auto login the user the first time they exist?

A bit more background...

During payment/ecommerce flows its pretty common to reduce friction and increase all important conversions by not forcing a user to leave the screen to dig through their emails when they just want to buy something. Ive seen many products doing this where the user will enter an email only, then become auto logged in instantly, then the email verification gets sent as an optional click to verify.

If the same email is used for a login attempt again, the regular flow applies (they get a magic link from the Email Provider they must click) to avoid account takeovers.

This essentially allows customers to continue on a flow uninterrupted, test a product, or even pay without being pushed off to email the very first time they show up (which I get is more secure, but obviously there is a tradeoff here). Then if they want to return back to their account they can do securely by clicking the link (by this time not a first time user and much more likely to stick around, so a lot better from a stickyness point of view)

Does anyone know how I could approach this? Eg I guess it is possible to manually create the Email Provider session on the first login (somehow turning off the verification email going out) but I'm a bit in the dark on how to go about that.

I saw a similar set up that does this in Auth0.js (thread here) but I havn't come across much discussion on this in the NextAuth world.

Thanks ahead for any help.

How to reproduce ā˜•ļø

Set up nextauth with the Email Provider.

Contributing šŸ™ŒšŸ½

Yes, I am willing to help answer this question in a PR

ifielder commented 1 year ago

This would be amazing,

I'm having a lot of churn by users having to leave the page to find login links

jasondainter commented 1 year ago

I have made a bit of progress towards this if it helps as a workaround:

Downsides/thinks I haven't quite figured out yet:

  1. On the first login I haven't yet been able to trigger an email that optionally lets them click it. Even though they auto log in the first time there is value in that email a) as a semi marketing ploy to get them to log back in and b) to verify the email in the DB which means they are a higher quality / more trusted user generally. Im thinking a way to do this could be to somehow trigger the Email Provider at the same time (whilst they already logged in with Credentials Provider automatically) which might achieve this effect.

  2. Using Credentials Provider and using any database adapter (which you need for Email Provider magic links) forces you in nextauth to user JWT authentification as opposed to sessions. There are messy workarounds but this seems to be (in my opinion a bad) design decision . This opens new problems like the fact JWT sessions make it very hard to invalidate (eg kick out) a user without more hackery.

  3. It probably makes sense to add some extra security here due to various reasons, firstly this flag being set (the first time user flag) cant be spoofed by the user locally, if so in theory they could take over another persons account if not done in a secure way. Similarly I'm not entirely sure using email alone (without password) in a JWT set up would be, I would guess it would be required to also use another unique identifier that couldnt be easily guessed to again avoid spoofed logins.

If anyone has attempted this before or knows how they would go about this I would love to pay for some contracting hours on this as some of the implementation on above is a bit above my pay grade as a noob developer and obviously want to make this secure.

stale[bot] commented 1 year ago

It looks like this issue did not receive any activity for 60 days. It will be closed in 7 days if no further activity occurs. If you think your issue is still relevant, commenting will keep it open. Thanks!

EliasTouil commented 11 months ago

@jasondainter I'm facing similar challenges, were you able to come up with a solution?

jasondainter commented 11 months ago

Hi yes I got this working with a bit of tweaking to first use email credentials (without a password) then after that swap to magic links any future times the user tries to use the same email. Seems to work well.

On mobile but can try dig out some of the code when I'm back at my desk if you lile.

EliasTouil commented 11 months ago

@jasondainter That's great thanks! I think I'll try a create an isolated reproduction and leave it here for future reference.

EliasTouil commented 10 months ago

@jasondainter I created an open repo with some code, I would love to have your input on this!

https://github.com/EliasTouil/uninterrupted-email-signup-next-auth-example

jasondainter commented 10 months ago

Hi, sorry for delay. Bit swamped at the moment but ill dump a few things here for you on how I got this working which might set you on the right path. There might be better ways to do this also but this seems to work for me.

My set up is using Fauna as DB (but any would apply) and I have a sign up flow in 3 parts where the user tests out the product, gives a name, email (no password) and proceeds to test out the product (at this point they are logged in automatically with CredentialsProvider).

Then later on if they come back and try to do the same thing with the same email, they are sent a magic link instead using EmailProvider (otherwise you could log into someone elses account by simply putting their email in).

The CredentialsProvider (passwordless) and EmailProvider (magic link) Set Up

export default {
  session: {
    strategy: "jwt",
  },
  pages: {
    signIn: "/auth/signin", // Custom signin that removes the CredetialsProvider signin form altogether to avoid a sign in with email only (as Step3.js can do for isFirstLogin tagged users).
    error: "/auth/login-failed", // Error code passed in query string as ?error=
    verifyRequest: "/auth/check-your-email", // (used for check email message)
  },

  providers: [
    // Credentials Provider
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        email: {
          label: "Email",
          type: "email",
          placeholder: "your@email.com",
        },
      },
      // Automatically authorize users signing in with credentials since the only place you can log in with this (on Step3.js) only shows this when isFirstLogin is true (meaning they dont exist already). This triggers an auto login with email only (no password). Note: this is only used for the first time login and a check is already made in Step3.js to ensure they are not already in the database by this point.
      authorize: async (credentials) => {
        // Split name into first and last name if they are not provided seperately so we can personalise emails with firstName.
        let { name, email } = credentials;

        let firstName, lastName;
        if (name) {
          let nameParts = name.split(" ");
          firstName = nameParts.shift();
          lastName = nameParts.join(" ");
        }

        const user = {
          firstName,
          lastName,
          name,
          email,
          lastLoginDate: q.Now(),
          provider: "credentials",
        };

        // Create the user in FaunaDB (we do this manually because the adapter does not do it for us like it does for the EmailProvider)
        await faunaClient.query(
          q.Create(q.Collection("users"), { data: user })
        );

        return Promise.resolve(user);
      },
    }),
   // Email Provider
    EmailProvider({
      server: {
        host: process.env.EMAIL_SERVER_HOST,
        port: process.env.EMAIL_SERVER_PORT,
        auth: {
          user: process.env.EMAIL_SERVER_USER,
          pass: process.env.EMAIL_SERVER_PASSWORD,
        },
      },
      from: process.env.EMAIL_FROM,
      // customise the magic link email with MJML template from emails/NextauthMagicLinkVerification.js.
      sendVerificationRequest,
    }),
  ],

Then in my Step3.js part of the form (where they sign in first time) I have

let isFirstLogin = false; // Used to determine if user is logging in for the first time to set wheteher to auto login or not

then the logic here to work out if it's their first time...

      // if the user has firstUserLogin set to true, then login using the credentials provider with auto login (happens only once then email provider is used)
    if (isFirstLogin) {
      signIn("credentials", {
        email,
        name, // pass the name to [..nextauth].js to split up into firstName and lastName and create to Fauna
        redirect: false, // don't the useEffect hook will redirect after login
      });
    } else {
      // if the user has firstUserLogin set to anything except true (not true), then login using the email provider

      window.localStorage.setItem("signedInFrom", "createFlow"); // Set a localStorage item to tell the app/MYSITENAME/index.js page that the user signed in from the basic login page so it can redirect to the pay page
      signIn("email", {
        email, //: "step3." + email,
        name, // unsure if works?
        redirect: true, // redirect to custom api/auth/check-your-email page which will then show a message to close the tab once the verify email has been clicked (this will open in a new tab due to default email client behaviour so was no obvious way around this)
      });
      // Set the emailSent flag to true so the useEffect can redirect after the email has been sent
      setEmailSent(true);
    }
  };

Here I check in my database (fauna) if the email for that user already exists, and if not then set an "isFirstLogin" flag that is used to determine which login method to use (eg EmailProvider or CredentialsProvider).

      // Check if the user exists in FaunaDB and set isFirstLogin accordingly (used after to determine which provider to use)
    await checkFaunaUser(email, "instant")
      .then(async (result) => {
        // make the callback function async
        if (result === "User not found") {
          isFirstLogin = true;
        } else {
          isFirstLogin = false;
        }
      })
      .catch((error) => {
        console.error("User check failed:", error.message);
        // Handle error, for example stop loading spinner and show error message
        setLoading(false);
        setError("User check failed: " + error.message);
      });

I have some stuff also modified on the magic link email that when clicked would take the user back to the payment flow if it was their 2nd+ time (where they would need to authenticate more securely via the magic link), using local storage and a useEffect that triggers when they land back on the site from the email (but this is a bit out of scope so not included that here)

Hope that helps get the ball rolling

EliasTouil commented 10 months ago

Hey @jasondainter Thanks a lot! I've got to about the same setup :) I'm glad we can leave some docs behind for anyone else looking to achieve the same goal! cheers

jasondainter commented 10 months ago

Great. Shout if something specific doesn't work and I can try compare to mine.

Would be great to see something implemented here as default, auto login is a super common need in e-commerce to reduce friction, and my understanding is its perfectly safe if you simply check the email on the 2nd occasion.

EliasTouil commented 10 months ago

@jasondainter Agreed, I'd love to see something like 'anonymous' user from Firebase. I've also implemented the same logic in a custom auth we had on a web app. I'ts great to be able to collect user info/actions before they become a user.

jasondainter commented 10 months ago

Re the magic link was editing this today a bit so thought I would share a bit re the local storage solution that works for me.

Per earlier I use this when the "email provider" (magic link) is conditionally used. To work that out per earlier code I run a test from the database to see if the users email already exists (if not they get auto logged in using the credentials provider, without a password).

In the situation where the magic link is sent (eg the user already exists) I found it troublesome to get the right data passing back to nextauth from that verifcation click so how I worked around that was using local storage.

Local storage gets set when the email provider (magic link) sign in happens eg:

window.localStorage.setItem("loggedInFrom", "productDemoFlow"); (obviously call whatever you like).

Then when the user logs in I have the general config set up to send them to a page (lets call it /dashboard) which has a react useEffect listening on there which looks a bit like:

  useEffect(() => {
    const from = window.localStorage.getItem("loggedInFrom");
    window.localStorage.removeItem("loggedInFrom");
        if (from === "productDemoFlow") {
      router.push("/back/to/your/existingflow"); /// rename to where you take them to.
    }
  }, []);

This way I was able to auto log in the user to avoid creating friction when trying/buying, but also in the scenario where they already has an account and need to use the magic link for obvious security reasons, they still end up back on the same next step page.

Hope that helps. Let me know how you get on with the open repo. Agree a lot of other tools offer auto login as an off the shelf feature so I would love to see this integrated into nextAuth some day without all this hackery.

EliasTouil commented 10 months ago

Yeah it's working all well. I also just finished implementing this in our real app and will be testing around this week!

I my case I just used the baked-in callbackUrl. In the case of email signIn, I just add the desired URL. I also have a "re-verify" logic since the user could've missed the e-mail or want to verify later on. The re-verify logic also uses the callbackUrl. I found more straightforward for my use case, but arguably if you want to support more login options in the future (such as Google or other OAuth providers), using local storage or cookies is a good way to keep track of what's going on.

Just for context, in our use case the users are going on a 'post your first message' flow on a forum.

Link to file in open repo

const MockUserFlowSignupPage = () => {
  const [email, setEmail] = useState("");
  const [needsMagicLink, setNeedsMagicLink] = useState(false);
  const router = useRouter();

  const handleSignup = async () => {
    signIn("onboarding-signup", {
      email,
      redirect: false,
      callbackUrl: "/mock-user-flow/post",
    }).then((res) => {
      if (!res)
        throw new Error("Unknown internal server error at signup, no response");
      if (res.error === "User already exists.") {
        console.log(
          "User already exists .... retyring with email provider to send verification e-mail"
        );

        signIn("email", {
          email,
          redirect: false,
          callbackUrl: "/mock-user-flow/post",
        });
        setNeedsMagicLink(true);
      }
      // You can provide a redirectUrl as second parameter
      sendAsyncVerificationEmail(email);
      router.push("/mock-user-flow/post");
    });
  };

  const session = useSession();

  return (
    <div className="flex flex-col justify-center items-center w-full h-[100dvh]">
      <div className="">{session.status}</div>
      <div className="w-[350px] h-[100%] flex flex-col items-stretch justify-center">
        <label htmlFor="email">Email</label>
        <input
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          type="email"
        />
        <button
          onClick={handleSignup}
          className="mt-8 rounded-lg text-sky-50 bg-sky-600"
          disabled={needsMagicLink}
        >
          Sign up {needsMagicLink && "(disabled)"}
        </button>
        {needsMagicLink && (
          <div className="mt-8">
            <p>
              You need to use the magic link to sign in. Check your email for a
              link.
            </p>
          </div>
        )}
      </div>
    </div>
  );
};