Implement Single Sign On in Medusa
Tags: javascript, programming, opensource, webdev
Published: 2023-02-02 14:34:17

## Introduction

[Medusa](https://medusajs.com) 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](https://github.com/carpad88/medusa-server-passwordless).

![Passwordless flow](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1ludx41gndtxa30172s4.gif)

## What is Medusa?

Medusa is a composable engine that combines an amazing developer experience with endless customizations. You can add additional [endpoints](https://docs.medusajs.com/advanced/backend/endpoints/add) to perform custom actions or add new [services](https://docs.medusajs.com/advanced/backend/services/create-service) to implement business logic; for example, to modify the authentication flow. You can also use [subscriptions](https://docs.medusajs.com/advanced/backend/subscribers/create-subscriber) 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](https://docs.medusajs.com/quickstart/quick-start/) to getting started.
2. Your Medusa server should be configured to work with PostgreSQL and Redis. You can follow the [Configure your Server documentation](https://docs.medusajs.com/usage/configurations/) guide to learn how to do that.
3. An email service to send emails to implement passwordless login: You can [follow this guide](https://docs.medusajs.com/add-plugins/sendgrid) to enable the Sendgrid plugin available on the Medusa ecosystem.
4. A [Next.js starter](https://docs.medusajs.com/starters/nextjs-medusa-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](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable) 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:

```bash
yarn add jsonwebtoken
```

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

```tsx
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
```

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:

```tsx
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
}
```

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:

```tsx
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;
```

The handler, `handlePasswordlessLogin`, uses the SendGrid service with a [dynamic template](https://docs.sendgrid.com/ui/sending-email/how-to-send-an-email-with-dynamic-templates) 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........................
```

## 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](https://docs.medusajs.com/starters/nextjs-medusa-starter/) 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:

```tsx
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
```

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:

```tsx
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
```

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:

```tsx
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
```

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:

```bash
yarn run build
```

Next, start your Medusa server  running the next command 

```tsx
yarn start
```

and start the Next.js Storefront:

```bash
yarn run dev
```

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

![Login page](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gojd4jczz33mxwrhnlad.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tg2mjtgksdbvvl9y8nos.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tgflh7lp9959f76pmbns.png)

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

![Registration email](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a8favy3x7k0ns1s8bpny.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/900ivlefa5oj9xf3xcau.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sy1l75lstyytd8k3ljph.png)

## 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](https://github.com/adrien2p/medusa-plugins/tree/main/packages/medusa-plugin-auth).
- Implement multifactor authentication (MFA) for administrators.

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

- Learn [how to extract your authentication functionality into a Medusa plugin](https://docs.medusajs.com/advanced/backend/plugins/overview).
- Learn [how to create a Subscriber](https://docs.medusajs.com/advanced/backend/subscribers/create-subscriber).
- Learn [how to send notifications](https://docs.medusajs.com/advanced/backend/notification/overview) when an event occurs.

> If you have any issues or questions about Medusa, feel free to contact the Medusa team via [Discord](https://discord.gg/F87eGuwkTp).