Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Last active April 16, 2024 00:59
Show Gist options
  • Star 16 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save ryanflorence/c9924d39a225c6e6948f5920ba7ffcb8 to your computer and use it in GitHub Desktop.
Save ryanflorence/c9924d39a225c6e6948f5920ba7ffcb8 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>
);
}
@mstaicu
Copy link

mstaicu commented Jun 1, 2022

Very cool! Could we get more information on the "auth-redirect" header? Can't find any informations on it

@seanpascoe
Copy link

Thanks! A couple small items for anyone using this:

  • the searchParam in generateMagicLink ("magic") should match the one in getMagicToken ("key")
  • the sessionMaxAge needs to be multiplied by 60 to get the desired 30 days.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment