UNPKG

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
"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;