Email Link Strategy - Remix Auth
This strategy is heavily based on kcd strategy present in the v2 of Remix Auth. The major difference being we are using crypto-js
instead of crypto
so that it can be deployed on CF.
The Email Link Strategy implements the authentication strategy used on kentcdodds.com.
This strategy uses passwordless flow with magic links. A magic link is a special URL generated when the user tries to login, this URL is sent to the user via email, after the click on it the user is automatically logged in.
You can read more about how this work in the kentcdodds.com/how-i-built-a-modern-website-in-2021.
Supported runtimes
Runtime | Has Support |
---|
Node.js | ✅ |
Cloudflare | ✅ |
How to use
Setup
Because of how this strategy works you need a little bit more setup than other strategies, but nothing specially crazy.
Email Service
You will need to have some email service configured in your application. What you actually use to send emails is not important, as far as you can create a function with this type:
type SendEmailOptions<User> = {
emailAddress: string
magicLink: string
user?: User | null
domainUrl: string
form: FormData
}
type SendEmailFunction<User> = {
(options: SendEmailOptions<User>): Promise<void>
}
So if you have something like app/services/email-provider.server.ts
file exposing a generic function like sendEmail
function receiving an email address, subject and body, you could use it like this:
import { renderToString } from 'react-dom/server'
import type { SendEmailFunction } from 'remix-auth-email-link'
import type { User } from '~/models/user.model'
import * as emailProvider from '~/services/email-provider.server'
export let sendEmail: SendEmailFunction<User> = async (options) => {
let subject = "Here's your Magic sign-in link"
let body = renderToString(
<p>
Hi {options.user?.name || 'there'},<br />
<br />
<a href={options.magicLink}>Click here to login on example.app</a>
</p>
)
await emailProvider.sendEmail(options.emailAddress, subject, body)
}
Again, what you use as email provider is not important, you could use a third party service like Mailgun or Sendgrid, if you are using AWS you could use SES.
Create the strategy instance
Now that you have your sendEmail email function you can create an instance of the Authenticator and the EmailLinkStrategy.
import { Authenticator } from 'remix-auth'
import { EmailLinkStrategy } from 'remix-auth-email-link'
import { sessionStorage } from '~/services/session.server'
import { sendEmail } from '~/services/email.server'
import { User, getUserByEmail } from '~/models/user.server'
let secret = process.env.MAGIC_LINK_SECRET
if (!secret) throw new Error('Missing MAGIC_LINK_SECRET env variable.')
export let auth = new Authenticator<User>(sessionStorage)
auth.use(
new EmailLinkStrategy(
{ sendEmail, secret, callbackURL: '/magic' },
async ({
email,
form,
magicLinkVerify,
}: {
email: string
form: FormData
magicLinkVerify: boolean
}) => {
let user = await getUserByEmail(email)
return user
}
)
)
Setup your routes
Now you can proceed to create your routes and do the setup.
import { ActionArgs, LoaderArgs } from '@remix-run/node'
import { json } from '@remix-run/node'
import { Form, useLoaderData } from '@remix-run/react'
import { auth } from '~/services/auth.server'
import { sessionStorage } from '~/services/session.server'
export let loader = async ({ request }: LoaderArgs) => {
await auth.isAuthenticated(request, { successRedirect: '/me' })
let session = await sessionStorage.getSession(request.headers.get('Cookie'))
return json({
magicLinkSent: session.has('auth:magiclink'),
magicLinkEmail: session.get('auth:email'),
})
}
export let action = async ({ request }: ActionArgs) => {
await auth.authenticate('email-link', request, {
successRedirect: '/login',
failureRedirect: '/login',
})
}
export default function Login() {
let { magicLinkSent, magicLinkEmail } = useLoaderData<typeof loader>()
return (
<Form action="/login" method="post">
{magicLinkSent ? (
<p>
Successfully sent magic link{' '}
{magicLinkEmail ? `to ${magicLinkEmail}` : ''}
</p>
) : (
<>
<h1>Log in to your account.</h1>
<div>
<label htmlFor="email">Email address</label>
<input id="email" type="email" name="email" required />
</div>
<button>Email a login link</button>
</>
)}
</Form>
)
}
import { LoaderArgs } from '@remix-run/node'
import { auth } from '~/services/auth.server'
export let loader = async ({ request }: LoaderArgs) => {
await auth.authenticate('email-link', request, {
successRedirect: '/me',
failureRedirect: '/login',
})
}
import { LoaderArgs } from '@remix-run/node'
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { auth } from '~/services/auth.server'
export let loader = async ({ request }: LoaderArgs) => {
let user = await auth.isAuthenticated(request, { failureRedirect: '/login' })
return json({ user })
}
export default function Me() {
let { user } = useLoaderData<typeof loader>()
return (
<div>
<h1>Welcome {user.name}</h1>
<p>You are logged in as {user.email}</p>
</div>
)
}
Email validation
The EmailLinkStrategy also supports email validation, this is useful if you want to prevent someone from signing-in with a disposable email address or you have some denylist of emails for some reason.
By default, the EmailStrategy will validate every email against the regular expression /.+@.+/
, if it doesn't pass it will throw an error.
If you want to customize it you can create a function with this type and pass it to the EmailLinkStrategy.
type VerifyEmailFunction = {
(email: string): Promise<void>
}
Example
import { VerifyEmailFunction } from 'remix-auth-email-link'
import { isEmailBurner } from 'burner-email-providers'
import isEmail from 'validator/lib/isEmail'
export let verifyEmailAddress: VerifyEmailFunction = async (email) => {
if (!isEmail(email)) throw new Error('Invalid email address.')
if (isEmailBurner(email)) throw new Error('Email not allowed.')
}
import { Authenticator } from 'remix-auth'
import { Authenticator, EmailLinkStrategy } from 'remix-auth-email-link'
import { sessionStorage } from '~/services/session.server'
import { sendEmail } from '~/services/email.server'
import { User, getUserByEmail } from '~/models/user.model'
import { verifyEmailAddress } from '~/services/verifier.server'
let secret = process.env.MAGIC_LINK_SECRET
if (!secret) throw new Error('Missing MAGIC_LINK_SECRET env variable.')
let auth = new Authenticator<User>(sessionStorage)
auth.use(
new EmailLinkStrategy(
{ verifyEmailAddress, sendEmail, secret, callbackURL: '/magic' },
async ({ email }: { email: string }) => {
let user = await getUserByEmail(email)
return user
}
)
)
Options options
The EmailLinkStrategy supports a few more optional configuration options you can set. Here's the whole type with each option commented.
type EmailLinkStrategyOptions<User> = {
callbackURL?: string
sendEmail: SendEmailFunction<User>
verifyEmailAddress?: VerifyEmailFunction
secret: string
emailField?: string
magicLinkSearchParam?: string
linkExpirationTime?: number
sessionErrorKey?: string
sessionMagicLinkKey?: string
validateSessionMagicLink?: boolean
sessionEmailKey?: string
}