DEV Community

Cover image for Implement Single Sign On in Medusa
Carlos Padilla
Carlos Padilla

Posted on

Implement Single Sign On in Medusa

Introduction

Medusa is an open source headless commerce that offers out-of-the-box functionalities needed to build an ecommerce platform. An important feature Medusa provides is its flexibility in adapting functionalities according to your needs.

Medusa allows users to authenticate on the platform using email and password or with a bearer token. However, the authentication flow can be customized due to Medusa's flexibility.

This tutorial will guide you through the process of customizing the auth flow of your Medusa server. To achieve that, you will allow your customers to log in with a magic link received in their email.

You can find the final code for this tutorial in this GitHub repository.

Passwordless flow

What is Medusa?

Medusa is a composable engine that combines an amazing developer experience with endless customizations. You can add additional endpoints to perform custom actions or add new services to implement business logic; for example, to modify the authentication flow. You can also use subscriptions to build automation; for example, to send an email using a notification provider when a specific event happens.

Prerequisites

To follow along with this tutorial, you need the following:

  1. A working Medusa server application: You can follow the quickstart guide to getting started.
  2. Your Medusa server should be configured to work with PostgreSQL and Redis. You can follow the Configure your Server documentation guide to learn how to do that.
  3. An email service to send emails to implement passwordless login: You can follow this guide to enable the Sendgrid plugin available on the Medusa ecosystem.
  4. A Next.js starter to test the new authentication flow added to the Medusa server. However, you can still follow along using a different storefront framework.
  5. yarn package manager: you can follow these instructions to install it. You can also use npm as an alternative.

Passwordless Login

A passwordless login is a strategy to verify a user's identity without a password. Several alternatives to achieve this include possession factors such as one-time passwords [OTPs] or registered smartphone numbers, biometrics using fingerprint or retina scans, and magic links sent by email to the user.

For this tutorial, you will focus on implementing the magic link strategy. See the flow below:

  1. Customers visit the Medusa store, write their email, and click the Enter button.
  2. The Medusa store requests the auth endpoint on the Medusa server, which sends an email to the customer with a magic link.
  3. Customers click on the magic link.
  4. The Medusa server validates the link, logs users in, sets a session cookie, and redirects customers to the Storefront.

Add Passwordless Service

The first step is creating a service that will emit an event to send emails with the magic link to customers and verify tokens from those magic links.

Start by installing the jsonwebtoken package that will be used to sign in and verify the tokens:

yarn add jsonwebtoken
Enter fullscreen mode Exit fullscreen mode

Next, create the file src/services/password-less.ts with the following content:

import { CustomerService, EventBusService, TransactionBaseService } from '@medusajs/medusa'
import { MedusaError } from 'medusa-core-utils'
import { EntityManager } from 'typeorm'
import jwt from 'jsonwebtoken'

class PasswordLessService extends TransactionBaseService {
  protected manager_: EntityManager
  protected transactionManager_: EntityManager
  private readonly customerService_: CustomerService
  private readonly eventBus_: EventBusService
  private readonly configModule_: any;
  private readonly jwt_secret: any;

  constructor(container) {
    super(container)
    this.eventBus_ = container.eventBusService
    this.customerService_ = container.customerService
    this.configModule_ = container.configModule

    const { projectConfig: { jwt_secret } } = this.configModule_
    this.jwt_secret = jwt_secret
  }

  async sendMagicLink(email, isSignUp) {
    const token = jwt.sign({ email }, this.jwt_secret, { expiresIn: '8h' })

    try {
      return await this.eventBus_.withTransaction(this.manager_)
        .emit('passwordless.login', { email, isSignUp, token })
    } catch (error) {
      throw new MedusaError(MedusaError.Types.UNEXPECTED_STATE, `There was an error sending the email.`)
    }
  }

  async validateMagicLink(token) {
    let decoded
    const { projectConfig: { jwt_secret } } = this.configModule_

    try {
      decoded = jwt.verify(token, jwt_secret)
    } catch (err) {
      throw new MedusaError(MedusaError.Types.INVALID_DATA, `Invalid auth credentials.`)
    }

    if (!decoded.hasOwnProperty('email') || !decoded.hasOwnProperty('exp')) {
      throw new MedusaError(MedusaError.Types.INVALID_DATA, `Invalid auth credentials.`)
    }

    const customer = await this.customerService_.retrieveRegisteredByEmail(decoded.email).catch(() => null)

    if (!customer) {
      throw new MedusaError(MedusaError.Types.NOT_FOUND, `There isn't a customer with email ${decoded.email}.`)
    }

    return customer
  }
}

export default PasswordLessService
Enter fullscreen mode Exit fullscreen mode

The first method, sendMagicLink, creates and signs a token with the customer's email received as a parameter and an expiration of one hour. Then, it emits the passwordles.login event using the eventBus, so the PasswordLessSubscriber, which you will create later, can execute its handler to send the email.

The validateMagicLink method verifies there is a token and that it is valid, that is, that it has not been manipulated by a third party or that it has not expired. Lastly, it tries to get the customer using the customerService, and if it’s successful, it returns the retrieved customer.

Add Passwordless Endpoints

The next step is to add custom endpoints, so you can use the strategy from the storefront.

Create the file src/api/index.ts with the following content:

import { Router, json } from 'express'
import cors from 'cors'
import jwt from 'jsonwebtoken'
import { projectConfig } from '../../medusa-config'

const corsOptions = {
  origin: projectConfig.store_cors.split(','),
  credentials: true
}

const route = Router()

export default () => {
  const app = Router()
  app.use(cors(corsOptions))
  app.use(json());

  app.use('/auth', route)

  route.post('/passwordless/sent', async (req, res) => {
    const manager = req.scope.resolve('manager')
    const customerService = req.scope.resolve('customerService')
    const { email, isSignUp } = req.body

    let customer = await customerService.retrieveRegisteredByEmail(email).catch(() => null)

    if (!customer && !isSignUp) {
      res.status(404).json({ message: `Customer with ${email} was not found. Please sign up instead.` })
    }

    if (!customer && isSignUp) {
      customer = await customerService.withTransaction(manager).create({
        email,
        first_name: '--',
        last_name: '--',
        has_account: true
      })
    }

    const passwordLessService = req.scope.resolve('passwordLessService')

    try {
      await passwordLessService.sendMagicLink(customer.email, isSignUp)
      return res.status(204).json({ message: 'Email sent' })
    } catch (error) {
      return res.status(404).json({ message: `There was an error sending the email.` })
    }
  })

  route.get('/passwordless/validate', async (req, res) => {
    const { token } = req.query
    const { projectConfig } = req.scope.resolve('configModule')

    if (!token) {
      return res.status(403).json({ message: 'The user cannot be verified' })
    }

    const passwordLessService = req.scope.resolve('passwordLessService')

    try {
      const loggedCustomer = await passwordLessService.validateMagicLink(token)

      req.session.jwt_store = jwt.sign(
        { customer_id: loggedCustomer.id },
        projectConfig.jwt_secret!,
        { expiresIn: '30d' }
      )

      return res.status(200).json({ ...loggedCustomer })
    } catch (error) {
      return res.status(403).json({ message: 'The user cannot be verified' })
    }
  })

  return app
}
Enter fullscreen mode Exit fullscreen mode

In this file, the /passwordless/sent endpoint does some validations before calling the passwordLessService to emit the event.

The validations include checking if there is a registered customer with that email. If not, it returns a response asking the customer to sign up first. If there is no registered customer, but the request is a signUp, it creates a new customer on the database.

Lastly, when the customer is already retrieved or created, it calls the sendMagicLink method on the passwordLessService to send the email.

The /passwordless/validate endpoint verifies the existence of a token in the query string and validates it with the validateMagicLink method on the passwordLessService. If the token is valid, the service will fetch and return the customer details so the endpoint can set a session and send a response to the storefront.

Add Passwordless Subscriber

The last step is creating a subscriber with a handler function to send emails with a magic link whenever a passwordless.login event is emitted.

To send the emails, this tutorial uses SendGrid as a notification provider, but you can use the email provider of your choice as the flow would be very similar. Create the file src/subscribers/password-less.ts with the following content:

class PasswordLessSubscriber {
  protected sendGridService: any;
  constructor({ eventBusService, sendgridService }) {
    this.sendGridService = sendgridService;
    eventBusService.subscribe('passwordless.login', this.handlePasswordlessLogin);
  }

  handlePasswordlessLogin = async (data) => {
    await this.sendGridService.sendEmail({
      to: data.email,
      from: process.env.SENDGRID_FROM,
      templateId: data.isSignUp ? process.env.SENGRID_REGISTER_TEMPLATE_ID : process.env.SENGRID_LOGIN_TEMPLATE_ID,
      dynamic_template_data: {
        token: data.token
      },
    })
  }
}

export default PasswordLessSubscriber;
Enter fullscreen mode Exit fullscreen mode

The handler, handlePasswordlessLogin, uses the SendGrid service with a dynamic template to send the magic link that customers will click to log in. This email includes a storefront URL with a token passed as query string. Customers can click or copy the URL to log in, there’s no need to type a password.

Be sure to have two templates on Sendgrid: one for signup and another for login. Also, remember to get their IDs and add those values to your .env file. These IDs will be retrieved by your PasswordLessSubscriber when sending an email.

SENGRID_LOGIN_TEMPLATE_ID=d-750a..........................
SENGRID_REGISTER_TEMPLATE_ID=d-03b........................
Enter fullscreen mode Exit fullscreen mode

Set up New Auth Flow on the Storefront

This section will test the new authentication strategy added to your Medusa server. For this tutorial, the Next.js starter Storefront is used as an example, but you can use any other framework, and the process would be very similar.

Login Page

Open the src/modules/account/components/login/index.tsx file and replace its content with the following code:

import { LOGIN_VIEW, useAccount } from "@lib/context/account-context"
import Button from "@modules/common/components/button"
import Input from "@modules/common/components/input"
import React, { useState } from "react"
import { FieldValues, useForm } from "react-hook-form"

interface SignInCredentials extends FieldValues {
  email: string
}

const Login = () => {
  const { loginView } = useAccount()
  const [_, setCurrentView] = loginView
  const [authError, setAuthError] = useState<string | undefined>(undefined)
  const [linkSent, setLinkSent] = useState<string | undefined>(undefined)

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignInCredentials>()

  const onSubmit = handleSubmit(async (credentials) => {
    const response = await fetch("http://localhost:9000/auth/passwordless/sent", {
        method: "POST",
        body: JSON.stringify(credentials),
        headers: { "Content-Type": "application/json" },
      },
    )

    if (!response.ok) response.json().then((data) => setAuthError(data.message))

    if (response.ok) setLinkSent("Check your email for a login link.")
  })

  return (
    <div className="max-w-sm w-full flex flex-col items-center">
      {!linkSent && (
        <>
          <h1 className="text-large-semi uppercase mb-6">Welcome back</h1>
          <p className="text-center text-base-regular text-gray-700 mb-8">
            Sign in to access an enhanced shopping experience.
          </p>
          <form className="w-full" onSubmit={onSubmit}>

            <div className="flex flex-col w-full gap-y-2">
              <Input
                label="Email"
                {...register("email", { required: "Email is required" })}
                autoComplete="email"
                errors={errors}
              />
            </div>

            {authError && !linkSent && (
              <div className="text-rose-500 w-full text-small-regular mt-2">
                {authError}
              </div>
            )}
            <Button className="mt-6">Enter</Button>
          </form>

          <span className="text-center text-gray-700 text-small-regular mt-6">
            Not a member?{" "}
            <button
              onClick={() => setCurrentView(LOGIN_VIEW.REGISTER)}
              className="underline"
            >
          Join us
        </button>.</span>
        </>
      )}

      {linkSent && (
        <>
          <h1 className="text-large-semi uppercase mb-6">Login link sent!</h1>
          <div className="bg-green-100 text-green-500 p-4 my-4 w-full">
            <span>{linkSent}</span>
          </div>
        </>
      )}
    </div>
  )
}

export default Login
Enter fullscreen mode Exit fullscreen mode

The main changes that can be observed are:

  • The update of the handleSubmit function to request the endpoint /auth/passwordless/sent to send an email with a magic link to the customer.
  • The removal of the password field.
  • A div element to show a successful notification if the email was sent correctly.

Registration Page

Open the src/modules/account/components/register/index.tsx file and replace its content with the following:

import { LOGIN_VIEW, useAccount } from "@lib/context/account-context"
import Button from "@modules/common/components/button"
import Input from "@modules/common/components/input"
import Link from "next/link"
import { useState } from "react"
import { FieldValues, useForm } from "react-hook-form"

interface RegisterCredentials extends FieldValues {
  email: string
}

const Register = () => {
  const { loginView } = useAccount()
  const [_, setCurrentView] = loginView
  const [authError, setAuthError] = useState<string | undefined>(undefined)
  const [linkSent, setLinkSent] = useState<string | undefined>(undefined)

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<RegisterCredentials>()

  const onSubmit = handleSubmit(async (credentials) => {
    credentials.isSignUp = true
    const response = await fetch("http://localhost:9000/auth/passwordless/sent", {
        method: "POST",
        body: JSON.stringify(credentials),
        headers: { "Content-Type": "application/json" },
      },
    )

    if (!response.ok) response.json().then((data) => setAuthError(data.message))

    if (response.ok) setLinkSent("Check your email to activate your account.")
  })

  return (
    <div className="max-w-sm flex flex-col items-center mt-12">
      {!linkSent && (
        <>

          <h1 className="text-large-semi uppercase mb-6">Become a Acme Member</h1>
          <p className="text-center text-base-regular text-gray-700 mb-4">
            Create your Acme Member profile, and get access to an enhanced shopping
            experience.
          </p>
          <form className="w-full flex flex-col" onSubmit={onSubmit}>
            <div className="flex flex-col w-full gap-y-2">
              <Input
                label="Email"
                {...register("email", { required: "Email is required" })}
                autoComplete="email"
                errors={errors}
              />
            </div>
            {authError && (
              <div>
            <span className="text-rose-500 w-full text-small-regular">
              {authError}
            </span>
              </div>
            )}
            <span className="text-center text-gray-700 text-small-regular mt-6">
          By creating an account, you agree to Acme&apos;s{" "}
              <Link href="/content/privacy-policy">
            <a className="underline">Privacy Policy</a>
          </Link>{" "}
              and{" "}
              <Link href="/content/terms-of-use">
            <a className="underline">Terms of Use</a>
          </Link>
          .
        </span>
            <Button className="mt-6">Join</Button>
          </form>
          <span className="text-center text-gray-700 text-small-regular mt-6">
        Already a member?{" "}
            <button
              onClick={() => setCurrentView(LOGIN_VIEW.SIGN_IN)}
              className="underline"
            >
          Sign in
        </button>
        .
      </span></>
      )}

      {linkSent && (
        <>
          <h1 className="text-large-semi uppercase mb-6">Registration complete!</h1>
          <div className="bg-green-100 text-green-500 p-4 my-4 w-full">
            <span>{linkSent}</span>
          </div>
        </>
      )}
    </div>
  )
}

export default Register
Enter fullscreen mode Exit fullscreen mode

The changes that you can watch there are:

  • The update of the handle function to request the endpoint /auth/passwordless/sent to send an email with a magic link to the customer after registration.
  • The removal of the first name, last name, password and phone fields on the registration form.
  • A div element to show a successful notification if the email was sent successfully.

Validation Page

Create the new page src/pages/account/validate.tsx with the following content:

import Layout from "@modules/layout/templates"
import React, { ReactElement, useEffect, useState } from "react"
import { useRouter } from "next/router"
import { useAccount } from "@lib/context/account-context"
import Spinner from "@modules/common/icons/spinner"

const Validate = () => {
  const { refetchCustomer, retrievingCustomer, customer } = useAccount()
  const [authError, setAuthError] = useState<string | undefined>(undefined)

  const router = useRouter()

  useEffect(() => {
    const token = router.query.token
    if (token) {
      fetch(`http://localhost:9000/auth/passwordless/validate?token=${token}`,
        { credentials: "include" })
        .then((response) => {
          if (!response.ok) {
            response.json().then((data) => setAuthError(data.message))
          }
          refetchCustomer()
        })
    }
  }, [refetchCustomer, retrievingCustomer, router])

  if (authError) {
    return (
      <div className="flex items-center justify-center w-full min-h-[640px] h-full text-red-600">
        The link to login is invalid or has expired.
      </div>
    )
  }

  if (retrievingCustomer || !customer) {
    return (
      <div className="flex items-center justify-center w-full min-h-[640px] h-full text-gray-900">
        <Spinner size={36} />
      </div>
    )
  }

  if (!retrievingCustomer && customer) {
    router.push("/account")
  }

  return <div></div>
}

Validate.getLayout = (page: ReactElement) => {
  return (
    <Layout>
      {page}
    </Layout>
  )
}

export default Validate
Enter fullscreen mode Exit fullscreen mode

When users click on the magic link received on their email, they will be redirected to the /account/validate page on the storefront. This page will send a request to the Medusa server to check if the token, passed as a query parameter in the magic link, is valid. If the token is valid, users will be logged in and redirected to the account page, if not an error message will be shown.

The storefront page URL where the user is redirected from the magic link has to match the URL added to the SendGrid templates.

Test Passwordless Strategy

To test out the passwordless strategy, first in your terminal, run the following commands to transpile the TypeScript files to JavaScript files on your Medusa server:

yarn run build
Enter fullscreen mode Exit fullscreen mode

Next, start your Medusa server running the next command

yarn start
Enter fullscreen mode Exit fullscreen mode

and start the Next.js Storefront:

yarn run dev
Enter fullscreen mode Exit fullscreen mode

Then, go to the URL http://localhost:8000. Click on the Account link on the navbar, and you should get this page.

Login page

You can't log in right now because you need to register first. Click on the Join us link below the Enter button, and you should get the registration page.

Registration page

Fill out the form with your email and click on the Join button. You should see a notification that says the registration was completed.

Succesful registration

Now, check your inbox email, and you should see a new email with the template you configured previously in Sendgrid.

Registration email

Click on the Activate account button. A new tab in your browser will be open on the URL http://localhost:8000/account/validate, if your magic link is valid you will be redirected to the profile page:

Profile page

Log out of your account, go to the login page and ask for a new magic link to log in. You should receive a new mail with the login template from Sendgrid with a new magic link. Click the Login button, and you will be logged in again without writing any password.

Email with magic link

What's Next?

As you can see, it is straightforward to customize the authentication flow on your Medusa server. From now on, you can:

  • Extend the passwordless strategy to send one-time passwords (OTP) by email or SMS.
  • Add social methods such as Facebook, Twitter, or GitHub using the Medusa Auth plugin.
  • Implement multifactor authentication (MFA) for administrators.

You can also check out the following resources to dive more into Medusa's core:

If you have any issues or questions about Medusa, feel free to contact the Medusa team via Discord.

Top comments (1)

Collapse
 
shahednasser profile image
Shahed Nasser

Thank you for writing this! 🙌🏻