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.
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:
- A working Medusa server application: You can follow the quickstart guide to getting started.
- 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.
- 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.
- 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.
-
yarn
package manager: you can follow these instructions to install it. You can also usenpm
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:
- Customers visit the Medusa store, write their email, and click the Enter button.
- The Medusa store requests the auth endpoint on the Medusa server, which sends an email to the customer with a magic link.
- Customers click on the magic link.
- 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
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
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
}
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;
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 yourPasswordLessSubscriber
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 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
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'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:
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:
yarn run build
Next, start your Medusa server running the next command
yarn start
and start the Next.js Storefront:
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.
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.
Fill out the form with your email and click on the Join button. You should see a notification that says the registration was completed.
Now, check your inbox email, and you should see a new email with the template you configured previously in Sendgrid.
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:
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.
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:
- Learn how to extract your authentication functionality into a Medusa plugin.
- Learn how to create a Subscriber.
- Learn how to send notifications when an event occurs.
If you have any issues or questions about Medusa, feel free to contact the Medusa team via Discord.
Top comments (1)
Thank you for writing this! 🙌🏻