remix-auth-email-link
Version:
> This strategy is heavily based on **kcd** strategy present in the [v2 of Remix Auth](https://github.com/sergiodxa/remix-auth/blob/v2.6.0/docs/strategies/kcd.md). The major difference being we are using `crypto-js` instead of `crypto` so that it can be d
252 lines (251 loc) • 11.2 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.EmailLinkStrategy = void 0;
const server_runtime_1 = require("@remix-run/server-runtime");
const crypto_js_1 = __importDefault(require("crypto-js"));
const remix_auth_1 = require("remix-auth");
const verifyEmailAddress = async (email) => {
if (!/.+@.+/u.test(email)) {
throw new Error('A valid email is required.');
}
};
class EmailLinkStrategy extends remix_auth_1.Strategy {
constructor(options, verify) {
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
super(verify);
this.name = 'email-link';
this.emailField = 'email';
this.sendEmail = options.sendEmail;
this.callbackURL = (_a = options.callbackURL) !== null && _a !== void 0 ? _a : '/magic';
this.secret = options.secret;
this.sessionErrorKey = (_b = options.sessionErrorKey) !== null && _b !== void 0 ? _b : 'auth:error';
this.sessionMagicLinkKey = (_c = options.sessionMagicLinkKey) !== null && _c !== void 0 ? _c : 'auth:magiclink';
this.validateEmail = (_d = options.verifyEmailAddress) !== null && _d !== void 0 ? _d : verifyEmailAddress;
this.emailField = (_e = options.emailField) !== null && _e !== void 0 ? _e : this.emailField;
this.magicLinkSearchParam = (_f = options.magicLinkSearchParam) !== null && _f !== void 0 ? _f : 'token';
this.linkExpirationTime = (_g = options.linkExpirationTime) !== null && _g !== void 0 ? _g : 1000 * 60 * 30; // 30 minutes
this.validateSessionMagicLink = (_h = options.validateSessionMagicLink) !== null && _h !== void 0 ? _h : false;
this.sessionEmailKey = (_j = options.sessionEmailKey) !== null && _j !== void 0 ? _j : 'auth:email';
}
async authenticate(request, sessionStorage, options) {
var _a;
const session = await sessionStorage.getSession(request.headers.get('Cookie'));
const form = new URLSearchParams(await request.text());
const formData = new FormData();
// Convert the URLSearchParams to FormData
for (const [name, value] of form) {
formData.append(name, value);
}
// This should only be called in an action if it's used to start the login process
if (request.method === 'POST') {
if (!options.successRedirect) {
throw new Error('Missing successRedirect. The successRedirect is required for POST requests.');
}
// get the email address from the request body
const emailAddress = form.get(this.emailField);
// if it doesn't have an email address,
if (!emailAddress || typeof emailAddress !== 'string') {
const message = 'Missing email address.';
if (!options.failureRedirect) {
throw new Error(message);
}
session.flash(this.sessionErrorKey, { message });
const cookie = await sessionStorage.commitSession(session);
throw (0, server_runtime_1.redirect)(options.failureRedirect, {
headers: { 'Set-Cookie': cookie },
});
}
try {
// Validate the email address
await this.validateEmail(emailAddress);
const domainUrl = this.getDomainURL(request);
const magicLink = await this.sendToken(emailAddress, domainUrl, formData);
session.set(this.sessionMagicLinkKey, await this.encrypt(magicLink));
session.set(this.sessionEmailKey, emailAddress);
throw (0, server_runtime_1.redirect)(options.successRedirect, {
headers: {
'Set-Cookie': await sessionStorage.commitSession(session),
},
});
}
catch (error) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (error.status === 302) {
// If it's a redirect, then just throw the redirect as it is
throw error;
}
if (!options.failureRedirect) {
throw error;
}
const { message } = error;
session.flash(this.sessionErrorKey, { message });
const cookie = await sessionStorage.commitSession(session);
throw (0, server_runtime_1.redirect)(options.failureRedirect, {
headers: { 'Set-Cookie': cookie },
});
}
}
let user;
try {
// If we get here, the user clicked on the magic link inside email
const magicLink = (_a = session.get(this.sessionMagicLinkKey)) !== null && _a !== void 0 ? _a : '';
const { emailAddress: email, form } = await this.validateMagicLink(request.url, await this.decrypt(magicLink));
// now that we have the user email we can call verify to get the user
user = await this.verify({ email, form, magicLinkVerify: true });
}
catch (error) {
// if something happens, we should redirect to the failureRedirect
// and flash the error message, or just throw the error if failureRedirect
// is not defined
if (!options.failureRedirect) {
throw error;
}
const { message } = error;
session.flash(this.sessionErrorKey, { message });
const cookie = await sessionStorage.commitSession(session);
throw (0, server_runtime_1.redirect)(options.failureRedirect, {
headers: { 'Set-Cookie': cookie },
});
}
if (!options.successRedirect) {
return user;
}
// remove the magic link and email from the session
session.unset(this.sessionMagicLinkKey);
session.unset(this.sessionEmailKey);
session.set(options.sessionKey, user);
const cookie = await sessionStorage.commitSession(session);
throw (0, server_runtime_1.redirect)(options.successRedirect, {
headers: { 'Set-Cookie': cookie },
});
}
async getMagicLink(emailAddress, domainUrl, form) {
const payload = this.createMagicLinkPayload(emailAddress, form);
const stringToEncrypt = JSON.stringify(payload);
const encryptedString = await this.encrypt(stringToEncrypt);
const url = new URL(domainUrl);
url.pathname = this.callbackURL;
url.searchParams.set(this.magicLinkSearchParam, encryptedString);
return url.toString();
}
getDomainURL(request) {
var _a, _b;
const host = (_a = request.headers.get('X-Forwarded-Host')) !== null && _a !== void 0 ? _a : request.headers.get('host');
if (!host) {
throw new Error('Could not determine domain URL.');
}
const protocol = host.includes('localhost') || host.includes('127.0.0.1')
? 'http'
: (_b = request.headers.get('X-Forwarded-Proto')) !== null && _b !== void 0 ? _b : 'https';
return `${protocol}://${host}`;
}
async sendToken(email, domainUrl, form) {
const magicLink = await this.getMagicLink(email, domainUrl, form);
const user = await this.verify({
email,
form,
magicLinkVerify: false,
}).catch(() => null);
await this.sendEmail({
emailAddress: email,
magicLink,
user,
domainUrl,
form,
});
return magicLink;
}
createFormPayload(form) {
const formKeys = [...form.keys()];
return formKeys.length === 1
? undefined
: Object.fromEntries(formKeys
.filter((key) => key !== this.emailField)
.map((key) => [
key,
form.getAll(key).length > 1 ? form.getAll(key) : form.get(key),
]));
}
createMagicLinkPayload(emailAddress, form) {
const formPayload = this.createFormPayload(form);
return {
e: emailAddress,
...(formPayload && { f: formPayload }),
c: Date.now(),
};
}
async encrypt(value) {
return crypto_js_1.default.AES.encrypt(value, this.secret).toString();
}
async decrypt(value) {
const bytes = crypto_js_1.default.AES.decrypt(value, this.secret);
return bytes.toString(crypto_js_1.default.enc.Utf8);
}
getMagicLinkCode(link) {
var _a;
try {
const url = new URL(link);
return (_a = url.searchParams.get(this.magicLinkSearchParam)) !== null && _a !== void 0 ? _a : '';
}
catch {
return '';
}
}
async validateMagicLink(requestUrl, sessionMagicLink) {
var _a;
const linkCode = this.getMagicLinkCode(requestUrl);
const sessionLinkCode = sessionMagicLink
? this.getMagicLinkCode(sessionMagicLink)
: null;
let emailAddress;
let linkCreationTime;
let form;
try {
const decryptedString = await this.decrypt(linkCode);
const payload = JSON.parse(decryptedString);
emailAddress = payload.e;
form = (_a = payload.f) !== null && _a !== void 0 ? _a : {};
form[this.emailField] = emailAddress;
linkCreationTime = payload.c;
}
catch (error) {
console.error(error);
throw new Error('Sign in link invalid. Please request a new one.');
}
if (typeof emailAddress !== 'string') {
throw new TypeError('Sign in link invalid. Please request a new one.');
}
if (this.validateSessionMagicLink) {
if (!sessionLinkCode) {
throw new Error('Sign in link invalid. Please request a new one.');
}
if (linkCode !== sessionLinkCode) {
throw new Error(`You must open the magic link on the same device it was created from for security reasons. Please request a new link.`);
}
}
if (typeof linkCreationTime !== 'number') {
throw new TypeError('Sign in link invalid. Please request a new one.');
}
const expirationTime = linkCreationTime + this.linkExpirationTime;
if (Date.now() > expirationTime) {
throw new Error('Magic link expired. Please request a new one.');
}
const formData = new FormData();
Object.keys(form).forEach((key) => {
if (Array.isArray(form[key])) {
;
form[key].forEach((value) => {
formData.append(key, value);
});
}
else {
formData.append(key, form[key]);
}
});
return { emailAddress, form: formData };
}
}
exports.EmailLinkStrategy = EmailLinkStrategy;