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
  • Save mstaicu/202a8d636d1d35b5cb28b7a54ea56638 to your computer and use it in GitHub Desktop.
Save mstaicu/202a8d636d1d35b5cb28b7a54ea56638 to your computer and use it in GitHub Desktop.

Revisions

  1. @ryanflorence ryanflorence revised this gist Sep 29, 2021. 1 changed file with 0 additions and 1 deletion.
    1 change: 0 additions & 1 deletion routes__auth.tsx
    Original file line number Diff line number Diff line change
    @@ -25,7 +25,6 @@ export default function Login() {
    type="email"
    name="email"
    placeholder="[email protected]"
    defaultValue="[email protected]"
    />
    </label>{" "}
    <button type="submit">Sign in</button>
  2. @ryanflorence ryanflorence revised this gist Sep 29, 2021. 2 changed files with 57 additions and 0 deletions.
    35 changes: 35 additions & 0 deletions routes__auth.tsx
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,35 @@
    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="[email protected]"
    defaultValue="[email protected]"
    />
    </label>{" "}
    <button type="submit">Sign in</button>
    </p>
    </Form>
    );
    }
    22 changes: 22 additions & 0 deletions routes__dashboard.tsx
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,22 @@
    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>
    );
    }
  3. @ryanflorence ryanflorence revised this gist Sep 29, 2021. 1 changed file with 22 additions and 25 deletions.
    47 changes: 22 additions & 25 deletions remix-magic-auth.tsx
    Original file line number Diff line number Diff line change
    @@ -5,30 +5,35 @@ import type { ActionFunction, LoaderFunction, Session } from "remix";
    import { createCookieSessionStorage, json, redirect } from "remix";

    /*******************************************************************************
    * 1. Before we can do anything, we need to make sure the environment has
    * 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 (!process.env.MAGIC_LINK_SALT)
    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 (!process.env.MAILGUN_KEY)
    if (typeof process.env.MAILGUN_KEY !== "string")
    throw new Error("Missing process.env.MAILGUN_KEY");

    if (!process.env.MAILGUN_DOMAIN)
    if (typeof process.env.MAILGUN_DOMAIN !== "string")
    throw new Error("Missing `process.env.MAILGUN_DOMAIN`");

    /*******************************************************************************
    * 2. It all starts with a "user session". A session is a fancy type of cookie
    * 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: {
    // TODO: use process.env
    secrets: ["not-very-secret"],
    secrets: [process.env.SESSION_SECRET],
    path: "/",
    sameSite: "lax",
    },
    @@ -38,7 +43,7 @@ export let authSession = createCookieSessionStorage({
    let sessionMaxAge = /*seconds*/ 60 * /*hrs*/ 24 * /*days*/ 30;

    /*******************************************************************************
    * 3. The whole point of authentication is to make sure we have a valid user
    * 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.
    @@ -51,7 +56,7 @@ let sessionMaxAge = /*seconds*/ 60 * /*hrs*/ 24 * /*days*/ 30;
    * have to worry about doing the redirects themselves. Code in the loader will
    * stop executing and this function peforms a redirect right here.
    *
    * 7. All future requests to loaders/actions that require a user session will
    * 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
    @@ -85,21 +90,21 @@ export async function getAuthSession(
    }

    /*******************************************************************************
    * 4. The user is redirected to this loader from `getAuthSession` if they haven't
    * 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 (5)
    * Now go to (4)
    *
    * 6. After the user clicks the link in their email we end up here again, but
    * 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 (7)
    * Now go up to (6)
    */
    export let signInLoader: LoaderFunction = async ({ request }) => {
    let magicToken = getMagicToken(request);
    @@ -126,12 +131,12 @@ export let signInLoader: LoaderFunction = async ({ request }) => {
    };

    /*******************************************************************************
    * 5. After the user submits the form with their email address, we read the POST
    * 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 (6)
    * No go back up to (5)
    */
    export let signInAction: ActionFunction = async ({ request }) => {
    let body = Object.fromEntries(new URLSearchParams(await request.text()));
    @@ -148,8 +153,6 @@ export let signInAction: ActionFunction = async ({ request }) => {
    return json("ok");
    };

    ////////////////////////////////////////////////////////////////////////////////
    // helpers
    function getMagicToken(request: Request) {
    let { searchParams } = new URL(request.url);
    return searchParams.get("key");
    @@ -173,13 +176,7 @@ function getReferrer(request: Request) {
    return "/dashboard";
    }

    /*******************************************************************************
    * Encryption of for the email link
    */

    // TODO: move to env
    let domain = "http://localhost:3000";
    let magicLinkSearchParam = "key";
    let magicLinkSearchParam = "magic";
    let linkExpirationTime = 1000 * 60 * 30;
    let algorithm = "aes-256-ctr";
    let ivLength = 16;
    @@ -223,7 +220,7 @@ export function generateMagicLink(email: string, landingPage: string) {
    };
    let stringToEncrypt = JSON.stringify(payload);
    let encryptedString = encrypt(stringToEncrypt);
    let url = new URL(domain);
    let url = new URL(process.env.ORIGIN as string);
    url.pathname = "/auth";
    url.searchParams.set(magicLinkSearchParam, encryptedString);
    return url.toString();
  4. @ryanflorence ryanflorence created this gist Sep 29, 2021.
    286 changes: 286 additions & 0 deletions remix-magic-auth.tsx
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,286 @@
    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";

    /*******************************************************************************
    * 1. 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 (!process.env.MAGIC_LINK_SALT)
    throw new Error("Missing `process.env.MAGIC_LINK_SALT`");

    if (!process.env.MAILGUN_KEY)
    throw new Error("Missing process.env.MAILGUN_KEY");

    if (!process.env.MAILGUN_DOMAIN)
    throw new Error("Missing `process.env.MAILGUN_DOMAIN`");

    /*******************************************************************************
    * 2. 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: {
    // TODO: use process.env
    secrets: ["not-very-secret"],
    path: "/",
    sameSite: "lax",
    },
    });

    // 30 days
    let sessionMaxAge = /*seconds*/ 60 * /*hrs*/ 24 * /*days*/ 30;

    /*******************************************************************************
    * 3. 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.
    *
    * 7. 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];
    }

    /*******************************************************************************
    * 4. 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 (5)
    *
    * 6. 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 (7)
    */
    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,
    }),
    },
    });
    };

    /*******************************************************************************
    * 5. 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 (6)
    */
    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");
    };

    ////////////////////////////////////////////////////////////////////////////////
    // helpers
    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";
    }

    /*******************************************************************************
    * Encryption of for the email link
    */

    // TODO: move to env
    let domain = "http://localhost:3000";
    let magicLinkSearchParam = "key";
    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(domain);
    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 <[email protected]>",
    to: email,
    subject: "Login to Local Host!",
    html,
    });
    }