nextAuth passwordless Strapi auth integration

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