nextAuth passwordless Strapi auth integration

Hello

Im building a ecommerce webshop using Strapi (backend) en Nextjs (frontend). I want to use nextAuth to authenticate the user (passwordless) but I have no clue how I can integrate this with Strapi so the user is authenticated in the Strapi API.

I looked at the documentation about custom auth provider in Strapi but I could not figure this out the configurate this for the nextAuth passwordless authentication.

Can someone help me with this? (I dont want to use the Magic passwordless auth)

Thanks in advance

Robin

Hi, not that I know the answer but I am on the same boat :wink:
I watched this guy “Alex the Enterprenerd” used magic for easy passwordless Auth but I too don’t want to lock into paid product here.

I too want to use nextAuth with passwordless flow and at the moment do not know how I am going to do it. I am too far away from this stage yet. But when I get to implementing authentication, I will have to dig deep into this topic.

After some brief investigations I was considering to just write a custom database adapter for nextAuth that would talk to strapi API under the hood. Basically, use strapi as a storage.

I also saw someone else approached it differently: How to use NextAuth.js with Strapi? · Discussion #574 · nextauthjs/next-auth · GitHub

And there was a blog explaining how to hook up strapi and nextAuth User Authentication with Next.js and Strapi
But I didn’t like the approach. It assumed that the nextAuth can access the DB that strapi uses.
Imagine if your frontend is hosted on Vercel or Netlify and strapi backend somewhere on your own AWS account. I would never consider exposing my internal DB to a public network directly.

I kinda like the approach of this article but im not sure about the same password. Is there another way to do that? Because I don’t know enough of user authentication to find a safe solution myself.
https://github.com/nextauthjs/next-auth/discussions/574#discussioncomment-517034

My concern with that approach is that there is some hidden mysterious password set for ALL users.
What if somehow someone get to know it? Suddenly, it’s a key to ALL users.

Would it be secure enough if you generate a random password for every users? Or will that bring other issues?

I’m also interetsed in this passwordless user auth approach, but I wonder if it is possible to give access only to certain emails when it is not a public site.

I had a similar requirement and didn’t want to have to expose the database URL to Next.JS or rely on a common password.

This is what I came up with:

File ./config/plugins.js:

(I happen to use AWS SES to send the user’s new password but that’s obviously not a requirement)

module.exports = ({ env }) => ({
  // ...
  email: {
    provider: 'amazon-ses',
    providerOptions: {
      key: env('AWS_SES_KEY'),
      secret: env('AWS_SES_SECRET'),
      amazon: 'https://email.us-east-1.amazonaws.com',
    },
    settings: {
      defaultFrom: 'noreply@example.com',
      defaultReplyTo: 'noreply@example.com',
    },
  },
  // ...
});

File ./extensions/users-permissions/config/routes.json:

{
  "routes": [
    {
      "method": "POST",
      "path": "/auth/passwordless",
      "handler": "Passwordless.callback",
      "config": {
        "policies": ["plugins::users-permissions.ratelimit"],
        "prefix": "",
        "description": "Login a user using a passwordless authentication",
        "tag": {
          "plugin": "users-permissions",
          "name": "User"
        }
      }
    }
  ]
}

File ./extensions/users-permissions/controllers/Passwordless.js:

"use strict";

/**
 * Passwordless.js controller
 *
 * @description: A set of functions called "actions" to support passwordless auth.
 */

/* eslint-disable no-useless-escape */
const { sanitizeEntity } = require("strapi-utils");

const emailRegExp =
  /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const formatError = (error) => [
  { messages: [{ id: error.id, message: error.message, field: error.field }] },
];

module.exports = {
  async callback(ctx) {
    const provider = "local";
    const params = ctx.request.body;

    // The identifier is required.
    if (!params.identifier) {
      return ctx.badRequest(
        null,
        formatError({
          id: "Auth.form.error.email.provide",
          message: "Please provide your username or your e-mail.",
        })
      );
    }

    const query = { provider };

    // Check if the provided identifier is an email or not.
    const isEmail = emailRegExp.test(params.identifier);

    // Set the identifier to the appropriate query field.
    if (isEmail) {
      query.email = params.identifier.toLowerCase();
    } else {
      query.username = params.identifier;
    }

    // Check if the user exists.
    const user = await strapi.query("user", "users-permissions").findOne(query);

    if (!user) {
      return ctx.badRequest(
        null,
        formatError({
          id: "Auth.form.error.invalid",
          message: "Identifier invalid.",
        })
      );
    }

    if (user.blocked === true) {
      return ctx.badRequest(
        null,
        formatError({
          id: "Auth.form.error.blocked",
          message: "Your account has been blocked by an administrator",
        })
      );
    }

    const pluginStore = await strapi.store({
      environment: "",
      type: "plugin",
      name: "users-permissions",
    });

    const settings = await pluginStore
      .get({ key: "email" })
      .then((storeEmail) => {
        try {
          return storeEmail["reset_password"].options;
        } catch (error) {
          return {};
        }
      });

    const plainPassword = generateRandomPassword(6);

    const password = await strapi.plugins[
      "users-permissions"
    ].services.user.hashPassword({
      password: plainPassword,
    });

    // Update the user.
    await strapi
      .query("user", "users-permissions")
      .update({ id: user.id }, { resetPasswordToken: null, password });

    const userInfo = sanitizeEntity(user, {
      model: strapi.query("user", "users-permissions").model,
    });

    const displayPassword =
      plainPassword.slice(0, 3) + "-" + plainPassword.slice(3, 6);

    settings.message = await strapi.plugins[
      "users-permissions"
    ].services.userspermissions.template(settings.message, {
      CODE: displayPassword,
    });

    settings.object = await strapi.plugins[
      "users-permissions"
    ].services.userspermissions.template(settings.object, {
      CODE: displayPassword,
      USER: userInfo,
    });

    try {
      // Send an email to the user.
      await strapi.plugins["email"].services.email.send({
        to: user.email,
        from:
          settings.from.email || settings.from.name
            ? `${settings.from.name} <${settings.from.email}>`
            : undefined,
        replyTo: settings.response_email,
        subject: settings.object,
        text: settings.message,
        html: settings.message,
      });
    } catch (err) {
      return ctx.badRequest(null, err);
    }

    ctx.send({ status: "OK" });
  },


};

function generateRandomPassword(length) {
  const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
  var result = "";
  for (var i = length; i > 0; --i)
    result += chars[Math.floor(Math.random() * chars.length)];
  return result;
}

Lastly, you need to make sure you allow access to the new route (/auth/passwordless) through your admin panel ( Settings > Roles > Public > Users-Permissions > Passwordless > Callback).

Also, you want to update the Reset Password email template to something like this:

Subject: Your confirmation code: <%= CODE %>

<p>Your confirmation code is below — enter it in your open browser window and we'll help you get signed in.</p>

<%= CODE %>

<p>If you didn’t request this email, there’s nothing to worry about — you can safely ignore it.</p>

Testing:

curl -s \
  -X POST \
  -H "content-type:application/json" \
  -d '{"identifier":"me@example.com"}' \
  http://localhost:1337/auth/passwordless  | jq .

(should send you an email with the new password)

curl -s \
  -X POST \
  -H "content-type:application/json" \
  -d '{"identifier":"me@example.com","password":"PASSWORD FROM EMAIL"}' \
  http://localhost:1337/auth/local | jq .
1 Like

Neat!

I too spent a few days going back and forth trying to marry Next Auth passwordless with Strapi.
I didn’t like the idea of letting next auth access my DB.

And eventually did what you did. I just added passwordless directly to Strapi.
But I didn’t realise I could leverage the password field itself. So I created the whole separate model “loginRequest” that stores the login code associated with the login attempt and the user.

How do you expire the random password though?

The rate limit policy has now been changed to a middleware.

See the example from a plugin extension below:

    plugin.routes['content-api'].routes.push({
        method: 'GET',
        path: '/auth/passwordless',
        handler: 'auth.passwordless',
        config: {
            // policies: ['plugins::users-permissions.ratelimit'],
            middlewares: ['plugin::users-permissions.rateLimit'],
            prefix: '',
        },
    })

Also, it might be worth mentioning that the plugin extension method is different now.

See “example of backend extension”: Plugins extension - Strapi Developer Docs