Locks hanging on a metal mesh fence

How to implement password reset with headless BigCommerce – Part 2: Implementation details

October 6, 2021

This post is the second part of a series of articles about implementing a password reset feature with BigCommerce. You can read the first part here; it sets up the background and gives an overview of the solution.

This second part goes into implementation details. That means code. So if that's what you're here for, you've come to the right place. The examples in this article all use Next.js, but they should be transferable to other js frameworks.

Before you begin, let's do a quick recap of the requirements.

  • Trigger an email when the customer wants to reset their password.
  • The email includes a link to the password reset page.
  • That link contains a token with all essential information.
  • Only your server can decode the token.
  • The token has a short lifetime.
  • The token can only be used once.

Flow

Before I get to the code, let me briefly walk you through the flow. If something below doesn't make sense to you, please be patient; I will explain it in due time.

  1. Customer triggers a password reset request to your API.
  2. The request contains the customer email and nothing more.
  3. Use the email to fetch the customer record.
  4. Use the OTT attribute id to create or update the OTT attribute value on the customer using the customer id from the returned customer record. The value should be a unique token that can be used later to verify the email token.
  5. Generate a JWT using the customer email, id and OTT.
  6. Send a password reset email containing a link to your site. If you use Next.js, the link should point to an API endpoint.
  7. The customer clicks the link in the email.
  8. The API reads the token from the URL param and sets the token as a secure HTTP header. The customer is then redirected to a page on the client where they can input their new password.
  9. The customer types in their new password and clicks submit, sending a request to the API and the token header will come along for the ride.
  10. The API decodes the token, fetches the customer and OTT attribute.
  11. The API then compares the decoded token's OTT with the OTT value set on the customer record. If they don't match (or it's missing in the customer record), you should throw an error and abort the operation.
  12. Update the customer record with the new password.
  13. Log the customer in using the new password.
  14. Remove the reset token header and return the logged-in customer object.

There are a few steps to get this working, so let's start at the top.

Create the OTT customer attribute

Before you can use the OTT attribute, you need to create it. You only need to do this once, so use whatever method you feel most comfortable with, curl, postman, insomnia; it doesn't matter. Make a POST request to /customers/attributes with the following body:

body.json
{
  "name": "OTT",
  "type": "string"
}

If you want to make the code reusable across multiple projects or environments, you should write logic to fetch the OTT attribute and grab the ID from the response instead as the ID could be different for every project.

Trigger the email

Create an API endpoint that accepts a POST request with the customer email as the only parameter. Then use that email to fetch the customer record using the GET /customers V3 endpoint. You need the returned customer id in order to upsert the customer attribute value.

Send a PUT request to /customers/attribute-values with the following request body format to upsert the customer attribute value.

ott.ts
import crypto from 'crypto'; 

function sha256Hash(text: string) {
  const hash = crypto.createHash('sha256');
  hash.update(text);
  return hash.digest('hex');
}

async function upsertCustomerOTT(customer) {
  const requestBody = {
    customer_id: customer.id,
    attribute_id: OTT_ID, // the attribute id of the OTT attribute you created earlier
    value: sha256Hash(`${customer.email}${Date.now().toString()}`)
  }
  // send the above request body to PUT /customers/attribute-values
  const ottAttributeValue = await upsertCustomerAttribute(requestBody);
  return ottAttributeValue;
}

Now that you have an OTT saved on the customer, you can create the password reset token, a JWT containing the customer email, ID, and OTT.

generate-password-reset-token.ts
import jwt from 'jsonwebtoken';

type Customer = {
  email: string;
  id: number;
}

function generatePasswordResetToken(
  customer: Customer,
  ott: string,
) {
  const secret = process.env.JWT_SECRET;
  const token = jwt.sign(
    {
      email: customer.email,
      ott,
      id: customer.id,
    },
    secret,
    {
      expiresIn: '10m',
    },
  );
  return token;
}

That's the token you will append to your password reset URL. I'm not going into detail, it's outside the scope of this post, but the gist of it is something like this:

send-email.ts
async function sendEmail(customerEmail, resetToken) {
  const passwordResetLink = `${siteUrl}/api/password-reset?token=${resetToken}`;
  // send email containing this link to the customerEmail
}

Create an API endpoint

You need an API endpoint that accepts GET and POST requests.

password-reset.ts
import { NextApiRequest, NextApiResponse } from 'next';

export default async function passwordReset(
  req: NextApiRequest,
  res: NextApiResponse,
): Promise<any> {
  const methods = {
    GET: () => getPasswordReset(req, res), // will be defined below
    POST: () => postPasswordReset(req, res), // will be defined below
    UNSUPPORTED: () => res.status(405).send() // You should set the Allow header here but I've cut it for brevity
  }
  const action = methods[req.method] || methods.UNSUPPORTED;
  return action()
}

The GET endpoint is simple; it should just grab the token from the URL, use the set-cookie HTTP header to set the token as a cookie, and then redirect to a client page where users can input their new password.

password-reset.tsx
import { NextApiRequest, NextApiResponse } from 'next';
import { serialize } from 'cookie';

function getPasswordReset(req: NextApiRequest, res: NextApiResponse) {
  res.setHeader(
    'set-cookie',
    serialize('reset_token', String(req.query.token), {
      httpOnly: true,
      sameSite: true,
      path: '/',
    }),
  );
  return res.redirect('/reset-password');
}

The client page you redirect to needs a password input field and a submit button. Submitting should send a request to your password reset endpoint, this time using POST.

The POST endpoint is more involved. The steps are:

  1. Decode the token from the HTTP cookie.
  2. Fetch customer and OTT.
  3. Compare OTT value with the decoded value from the password reset token. Throw an error if they don't match.
  4. Update the customer's password with the new value (there's a gotcha here that I'll get to in a minute).
  5. Delete the OTT from the customer record.
  6. Remove the password reset cookie by setting the set-cookie header with an expiration date in the past.
  7. Optional. Log the customer in using their new password and return the user session.

The code for the above should look something like below.

password-reset.tsx
import jwt from 'jsonwebtoken';
import { NextApiRequest, NextApiResponse } from 'next';
import { serialize } from 'cookie';

function decodePasswordResetToken(token: string) {
  const secret = process.env.JWT_SECRET;
  const verified = jwt.verify(token, secret);
  return verified;
}

async function postResetPassword(req: NextApiRequest, res: NextApiResponse,) {
  const resetToken = req.cookies.reset_token;
  const decodedToken = decodePasswordResetToken(resetToken);
  const newPassword = req.body.password;
  const customer = await fetchCustomerWithAttributes(decodedToken.email); // important to use the email from the decoded token here. Also make sure you get the customer attributes
  const customerOtt = customer[0].attributes.find(
    (attribute) => attribute.attribute_id === OTT_ID, // The OTT id you saved earlier
  );
  if (customerOtt.attribute_value !== decodedToken.ott) {
    throw new ForbiddenError('Invalid token');
  }
  await Promise.all([
    updateCustomer({
      id: decodedToken.id, // once again, use the id from the token
      authentication: {
        new_password,
        force_password_reset: false, // here's the gotcha.
      },
    }),
    deleteCustomerAttribute({ id: customerOtt.id }), // function to delete the OTT attribute value from the customer.
  ]);
  
  res.setHeader(
    'set-cookie',
    serialize('reset_token', '', {
      httpOnly: true,
      sameSite: true,
      maxAge: -1, // I had trouble getting Next.js to remove the cookie if I just set the max age. I had to also set an expiration date in the past.
      expires: new Date(0),
      path: '/',
    }),
  );
  // Optional step. Since you have the customer's new password you can log them in and return the session using your standard login method.
  const data = await login(
    {
      ...req,
      body: { password: newPassword, email: decodedToken.email },
    } as NextApiRequest,
    res,
  );
  return res.status(200).send(data);
}

Gotcha!

If you don't explicitly set the force_password_reset value to false, BigCommerce will send out a new password reset email. This time using the transactional email template. It will point the customer to the storefront subdomain (where you host the checkout) and will thus be useless to you. It's a strange default value for the field, especially when the new_password is included as it renders the new password useless. I don't think this has always been the case because I had this working without this weirdness when I first implemented auth, but in the months since then it broke and this was the fix.

Summary

There you have it. While I've skipped some implementation details here and there, it should be enough for you to adapt to your project and get the password reset with headless BigCommerce working.

Since I started working on this article BigCommerce reached out to me, as well as a handful of other devs, and asked for feedback on this issue and auth in general for headless sites. I truly appreciate their effort and willingness to improve. I hope that this blog series will one day become obsolete. But until then, I hope you find it useful.