Skip to content

Instantly share code, notes, and snippets.

@mstaicu
Forked from ryanflorence/remix-magic-auth.tsx
Created June 11, 2022 13:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mstaicu/202a8d636d1d35b5cb28b7a54ea56638 to your computer and use it in GitHub Desktop.
Save mstaicu/202a8d636d1d35b5cb28b7a54ea56638 to your computer and use it in GitHub Desktop.
import crypto from "crypto";
import { renderToStaticMarkup } from "react-dom/server";
import createMailgun from "mailgun-js";
import type { ActionFunction, LoaderFunction, Session } from "remix";
import { createCookieSessionStorage, json, redirect } from "remix";
/*******************************************************************************
* Before we can do anything, we need to make sure the environment has
* everything we need. If anything is missing, we just prevent the app from
* starting up.
*/
if (typeof process.env.ORIGIN !== "string")
throw new Error("Missing `process.env.ORIGIN`");
if (typeof process.env.SESSION_SECRET !== "string")
throw new Error("Missing `process.env.SESSION_SECRET`");
if (typeof process.env.MAGIC_LINK_SALT !== "string")
throw new Error("Missing `process.env.MAGIC_LINK_SALT`");
if (typeof process.env.MAILGUN_KEY !== "string")
throw new Error("Missing process.env.MAILGUN_KEY");
if (typeof process.env.MAILGUN_DOMAIN !== "string")
throw new Error("Missing `process.env.MAILGUN_DOMAIN`");
/*******************************************************************************
* 1. It all starts with a "user session". A session is a fancy type of cookie
* that references data either in the cookie directly or in some other storage
* like a database (and the cookie holds value that can access the other
* storage). In our case we're going to keep the data in the cookie itself since
* we don't know what kind of database you've got.
*/
export let authSession = createCookieSessionStorage({
cookie: {
secrets: [process.env.SESSION_SECRET],
path: "/",
sameSite: "lax",
},
});
// 30 days
let sessionMaxAge = /*seconds*/ 60 * /*hrs*/ 24 * /*days*/ 30;
/*******************************************************************************
* 2. The whole point of authentication is to make sure we have a valid user
* before showing them some pages. This function protects pages from
* unauthenticated users. You call this from any loader/action that needs
* authentication.
*
* This function will return the user session (with a way to refresh it, we'll
* talk about that when you get to (7)). If there isn't a session, it redirects
* to the "/auth" route by throwing a redirect response.
*
* Because you can `throw` a response in Remix, your loaders and actions don't
* have to worry about doing the redirects themselves. Code in the loader will
* stop executing and this function peforms a redirect right here.
*
* 6. All future requests to loaders/actions that require a user session will
* call this function and they'll get the session instead of a login redirect.
* Sessions are stored with cookies which have a "max age" value. This is how
* long you want the browser to hang on to the cookie. The `refresh` function
* allows loaders and actions to "refresh" the max age so it's always "since the
* user last used it". If we didn't refresh, then sessions would always expire
* even if the user is on your site every day.
*/
export async function getAuthSession(
request: Request
): Promise<[Session, () => Promise<Headers>]> {
let cookie = request.headers.get("cookie");
let session = await authSession.getSession(cookie);
if (!session.has("auth")) {
throw redirect("/auth", {
status: 303,
headers: {
"auth-redirect": getReferrer(request),
},
});
}
let refresh = async () =>
new Headers({
"Set-Cookie": await authSession.commitSession(session, {
maxAge: sessionMaxAge,
}),
});
return [session, refresh];
}
/*******************************************************************************
* 3. The user is redirected to this loader from `getAuthSession` if they haven't
* logged in yet. This loader is also used to validate tokens, but right now there
* isn't a token so it just renders the route with a "referrer" so the token can
* log them into the right page later. We'll be back here soon for that part.
*
* Now go to (4)
*
* 5. After the user clicks the link in their email we end up here again, but
* this time we have a token in the URL. If it's valid, we set "auth" in the
* session as we redirect to the landing page. We've got a user session!
*
* You might also do some work with your database here, like create a user
* record.
*
* Now go up to (6)
*/
export let signInLoader: LoaderFunction = async ({ request }) => {
let magicToken = getMagicToken(request);
if (typeof magicToken !== "string") {
return json({ landingPage: getReferrer(request) });
}
let magicLinkPayload = getMagicLink(magicToken);
// might want to create user in the db
// might want to create a db session instead of a cookie session
// might set the user.id or session.id from a db instead of email
let session = await authSession.getSession();
session.set("auth", magicLinkPayload.email);
return redirect(magicLinkPayload.landingPage, {
headers: {
"Set-Cookie": await authSession.commitSession(session, {
maxAge: sessionMaxAge,
}),
},
});
};
/*******************************************************************************
* 4. After the user submits the form with their email address, we read the POST
* body from the request, validate it, send the email, and finally render the
* same route again but this time with action data. The UI then tells them to
* check their email.
*
* No go back up to (5)
*/
export let signInAction: ActionFunction = async ({ request }) => {
let body = Object.fromEntries(new URLSearchParams(await request.text()));
if (typeof body.email !== "string" || body.email.indexOf("@") === -1) {
throw json("Missing email", { status: 400 });
}
if (typeof body.landingPage !== "string") {
throw json("Missing landing page", { status: 400 });
}
await sendMagicLinkEmail(body.email, body.landingPage);
return json("ok");
};
function getMagicToken(request: Request) {
let { searchParams } = new URL(request.url);
return searchParams.get("key");
}
function getMagicLink(magicToken: string) {
try {
return validateMagicLink(magicToken);
} catch (e) {
throw json("Invalid magic link", { status: 400 });
}
}
function getReferrer(request: Request) {
// This doesn't work with all remix adapters yet, so pick a good default
let referrer = request.referrer;
if (referrer) {
let url = new URL(referrer);
return url.pathname + url.search;
}
return "/dashboard";
}
let magicLinkSearchParam = "magic";
let linkExpirationTime = 1000 * 60 * 30;
let algorithm = "aes-256-ctr";
let ivLength = 16;
let encryptionKey = crypto.scryptSync(process.env.MAGIC_LINK_SALT, "salt", 32);
function encrypt(text: string) {
let iv = crypto.randomBytes(ivLength);
let cipher = crypto.createCipheriv(algorithm, encryptionKey, iv);
let encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
return `${iv.toString("hex")}:${encrypted.toString("hex")}`;
}
function decrypt(text: string) {
let [ivPart, encryptedPart] = text.split(":");
if (!ivPart || !encryptedPart) {
throw new Error("Invalid text.");
}
let iv = Buffer.from(ivPart, "hex");
let encryptedText = Buffer.from(encryptedPart, "hex");
let decipher = crypto.createDecipheriv(algorithm, encryptionKey, iv);
let decrypted = Buffer.concat([
decipher.update(encryptedText),
decipher.final(),
]);
return decrypted.toString();
}
type MagicLinkPayload = {
email: string;
landingPage: string;
creationDate: string;
};
export function generateMagicLink(email: string, landingPage: string) {
let payload: MagicLinkPayload = {
email,
landingPage,
creationDate: new Date().toISOString(),
};
let stringToEncrypt = JSON.stringify(payload);
let encryptedString = encrypt(stringToEncrypt);
let url = new URL(process.env.ORIGIN as string);
url.pathname = "/auth";
url.searchParams.set(magicLinkSearchParam, encryptedString);
return url.toString();
}
function isMagicLinkPayload(obj: any): obj is MagicLinkPayload {
return (
typeof obj === "object" &&
typeof obj.email === "string" &&
typeof obj.landingPage === "string" &&
typeof obj.creationDate === "string"
);
}
export function validateMagicLink(link: string) {
let decryptedString = decrypt(link);
let payload = JSON.parse(decryptedString);
if (!isMagicLinkPayload(payload)) {
throw new Error("Invalid magic link");
}
let linkCreationDate = new Date(payload.creationDate);
let expirationTime = linkCreationDate.getTime() + linkExpirationTime;
if (Date.now() > expirationTime) {
throw new Error("Invalid magic link");
}
return payload;
}
/*******************************************************************************
* Email handled by mailgun
*/
let mailgun = createMailgun({
apiKey: process.env.MAILGUN_KEY,
domain: process.env.MAILGUN_DOMAIN,
});
export async function sendMagicLinkEmail(email: string, landingPage: string) {
let link = generateMagicLink(email, landingPage);
let html = renderToStaticMarkup(
<>
<p style={{ fontWeight: "bold" }}>Magic link demo email.</p>
<p>(kinda cool we can use JSX to write html email yeah?!)</p>
<p>
Just click this <a href={link}>link</a> and you're logged in!
</p>
</>
);
return mailgun.messages().send({
from: "Remix Magic Link Demo <you@example.com>",
to: email,
subject: "Login to Local Host!",
html,
});
}
import { useActionData, useLoaderData, Form } from "remix";
// Re-export magic auth action/loader for this route's action/loader
export {
signInAction as action,
signInLoader as loader,
} from "~/util/magic-auth";
// Build your UI, just have to post to "/auth"
export default function Login() {
let actionData = useActionData();
let loaderData = useLoaderData();
if (actionData === "ok") {
return <h1>Check your email!</h1>;
}
return (
<Form method="post" action="/auth">
<input type="hidden" name="landingPage" value={loaderData.landingPage} />
<p>
<label>
Email:{" "}
<input
type="email"
name="email"
placeholder="you@example.com"
/>
</label>{" "}
<button type="submit">Sign in</button>
</p>
</Form>
);
}
import { json, useLoaderData } from "remix";
import type { LoaderFunction } from "remix";
import { getAuthSession } from "~/util/magic-auth";
export let loader: LoaderFunction = async ({ request }) => {
let [session, getRefreshAuthHeaders] = await getAuthSession(request);
return json(
{ email: session.get("auth") },
{ headers: await getRefreshAuthHeaders() }
);
};
export default function Dashboard() {
let data = useLoaderData();
return (
<div>
<h1>Dashboard</h1>
<p>Welcome: {data.email}.</p>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment