Simple auth with Deno's Fresh + Supabase

morlinbrot

Zisulin Morbrot

Posted on December 16, 2022

Simple auth with Deno's Fresh + Supabase

Contents

  1. Getting Started
  2. Set up a theme and some components
  3. Pseudo-authentication with cookies
  4. Actual authentication with Supabase
  5. Add auth middleware and protected route
  6. Persist sessions with Redis
  7. Wrap up

Intro

For me, the one thing that comes up almost immediately for anything I want to build on the web is authentication. Maybe it's my background in building enterprise-grade web apps but I just can't imagine any actually useful app that would not involve some kind of functionality that is only available to signed in users. And since I wanted to try out Deno's Fresh framework for the longest time, I thought I'd combine it with a simple backend built with Supabase to create a small app with a simple cookie-based authentication scheme.

With this article, I wanted combine my irrational lust for trying out hot new fresh technologies (seriously, have you seen that logo?) with an at least somewhat level-headed evaluation of its suitability to be used for a real-world, production use case.

That evaluation mainly consists of two things for me: How does implementation of a non-trivial functionality like authentication look like? Is the framework suited to setting up a well organised code base, does it make it easy to do the simple stuff but provide enough flexibility to accommodate more complex business logic?

So, if you want to join me on that endeavour, let's get started!

Oh, and by the way, here's the project's code and here you can try it out in action!

Getting Started

Let's set up our new app by running the following commands:

deno run -A -r https://fresh.deno.dev my-auth-app
Enter fullscreen mode Exit fullscreen mode

Go ahead and confirm setting up the Tailwind integration, you probably also want the VSCode integration although Helix is obviously the only editor that will earn you a star in my book.

There are some things that we don't need from what's been generated, so let's remove them:

rm -rf islands/Counter.tsx routes/api/joke.ts routes/\[name\].tsx components/Button.tsx
Enter fullscreen mode Exit fullscreen mode

Let's add all the dependencies that we're going to need right away. Add this to your import_map.json:

{
  "imports": {
    "//": "omitted...",

    "dotenv/": "https://deno.land/x/dotenv@v3.2.0/",
    "redis": "https://deno.land/x/redis@v0.27.2/mod.ts",
    "std/": "https://deno.land/std@0.160.0/",
    "supabase": "https://esm.sh/@supabase/supabase-js@2.1.0",
  }
}
Enter fullscreen mode Exit fullscreen mode

The details matter here. Note that the supabase import does not have a / at the end. Also, it's an esm import because there is no official supabase package on Deno.land at the time of writing. Using the esm import is actually their recommended way of using Supabase from Deno right now, as per their issue tracker.

Let's then clean up routes/index.tsx and remove the components we're not using anymore. The file should now looks like this:

// routes/index.tsx

import { Head } from "$fresh/runtime.ts";

export default function Home() {
  return (
    <>
      <Head>
        <title>Supa Fresh Auth</title>
      </Head>
      <div class="p-4 mx-auto max-w-screen-md">
        <img src="/logo.svg" class="w-32 h-32" alt="the fresh logo: a sliced lemon dripping with juice" />
        <p class="my-6">
          Welcome to `fresh`. Try updating this message in the ./routes/index.tsx
          file, and refresh.
        </p>
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Marketing really outdid themselves this time coming up with the product name!

Now, I don't know about you but for me a dev server belongs on port 8080, everything else is just ludicrous. That's why I added this to my main.ts:

await start(manifest, { plugins: [twindPlugin(twindConfig)], port: 8080 });
Enter fullscreen mode Exit fullscreen mode

Ok, time to run the app for the first time!

deno task start
Enter fullscreen mode Exit fullscreen mode

Dripping.

Set up a theme and some components

Now, before we go any further, let's quickly add some styling and some components to have it out of the way.

The design department has still not delivered our new corporate design so I guess we'll have to do it ourselves. I know styling is a very subjective thing so go ahead and fiddle around with the theme to your heart's content, I'll wait here until you're back in two days, when you finally get out of that rabbit hole (Been there, done that so many times...).

Here's what I did to my twind.config.js:

// twind.config.ts

import { Options } from "$fresh/plugins/twind.ts";
import * as colors from "twind/colors";

export default {
  selfURL: import.meta.url,
  theme: {
    colors,
    extend: {
      colors: {
        primary: "#e879f9",
        primaryStrong: "#d946ef",
        primaryLight: "#f0abfc",
      },
    },
  },
  preflight: (preflight, { theme: _theme }) => ({
    ...preflight,
    div: {
      alignItems: "center",
    },
    h1: {
      fontSize: "1.5rem",
      fontWeight: "bold",
    },
    h2: {
      fontSize: "1.5rem",
      fontWeight: "bold",
    },
    p: {
      margin: "8px 0px 8px 0px",
    }
  })
} as Options;
Enter fullscreen mode Exit fullscreen mode

Have a look at the Tailwind or Twind docs for all the options.

I also like to build out even small projects in a way that it is at least halfway realistic and could scale up and grow into an actual app. For this, we should definitely create some building blocks to build our app with. Let's get that out of the way so we can fully focus on building the functionality laterj on.

The Fresh docs provide a small collection of components that provide a great starting point for our use case. You can have a look at this article's repo to see which components I created. Copy the components folder into your project or create your own.

Now we update routes/index.js again to get rid of some of the clutter:

// routes/index.tsx

import { Layout } from "components/index.ts";

export default function Home() {
  return (
    <Layout isAllowed={false}>
      <img src="/logo.svg" class="w-32 h-32" alt="the fresh logo: a sliced lemon dripping with juice" />
      <p class="my-6">
        Welcome to `fresh`. Try updating this message in the ./routes/index.tsx
        file, and refresh.
      </p>
    </Layout>
  );
}
Enter fullscreen mode Exit fullscreen mode

It's also a good idea to add the following entries to the import_map.json for convenience and cleanliness:

{
  "imports": {
    "//": "omitted...",

    "components/": "./components/",
    "islands/": "./islands/",
    "lib/": "./lib/",
    "routes/": "./routes/"
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's make sure everything is up and running...

deno task start
Enter fullscreen mode Exit fullscreen mode

Neat!

Pseudo-authentication with cookies

Alright, let's get going on some auth!

This section will closely follow this article from the Deno blog, have a look if you're interested. We will be introducing some slight differences, though, so it's probably best to just follow along here.

Add a SignInForm island

Well, we need a way to actually sign in to our little app, don't we? Let's add a SignInForm component in the islands folder.

Here's an interesting tid bit: Some of our components have an IS_BROWSER check in them to determine if they're disabled or not. If you add the SignInForm as a "dry" component, this won't work with the server side rendering of the form. I assume that there must at least be one island that is being delivered for Fresh to include its runtime into the bundle where IS_BROWSER is being imported from. If you know any details on this, let me know!

// islands/SignInForm.tsx

import { FormButton, Input } from "components/index.ts";

export default function SignInForm() {
  return (
    <div class="items-stretch min-w-0">
      <div class="flex justify-center">
        <h2 class="my-4">Sign In</h2>
      </div>

      <form method="post" class="flex flex-col space-y-4 min-w-0">
        <Input autofocus type="email" name="email" />
        <Input type="password" name="password" />

        <FormButton type="submit" formAction="/api/sign-in" class="!mt-8">
          Sign In
        </FormButton>

      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Update the home page

Let's update our home page with the form and a paragraph that's telling us the current auth status of the user.

// routes/index.tsx

// ...

import SignInForm from "islands/SignInForm.tsx";

export type Data = {
  isAllowed: boolean;
};

export const handler: Handlers = {
  GET(req, ctx) {
    const cookies = getCookies(req.headers);
    return ctx.render({ isAllowed: cookies.auth == "superzitrone" });
  }
}

export default function Home({ data: { isAllowed }}: PageProps<Data>) {
  return (
    <Layout isAllowed={isAllowed}>
      <img src="/logo.svg" class="w-32 h-32" alt="the fresh logo: a sliced lemon dripping with juice" />

      <p class="my-6">You are currently {!isAllowed && "not"} signed in.</p>

      {!isAllowed ? <SignInForm /> : <a href="/api/sign-out">Sign Out</a>}
    </Layout>
  );
}
Enter fullscreen mode Exit fullscreen mode

There are a few interesting things happening here. In Fresh, a route actually consists of two parts: a page component and a handler, which we are introducing here. Handlers are called each time a request is being made to a particular route. It receives the Request object and must return a Response, where calling ctx.render() actually returns a response under the hood.

By the way, this is an example of file-based routing, which is all the rage right now in the industry. So, yay us for hopping aboard the hype train!

So, as you can see, we respond to any GET request by rendering our index page, injecting an isAllowed prop along the way. And yes, all we're doing right now is looking for the magic string "superzitrone" in an auth cookie. Come to think of it, that may have been an awesome name for this project...

Anyways, we should now have a pretty looking sign-in form on the home page when we're not signed in - but it doesn't do anything yet.

Set up a sign-in route

Let's add a sign-in route to routes/api. This folder is another example of file-based routing and will get special treatment by Fresh. As you may have guessed, anything in here will be mounted as an api endpoint and can be called to provide any non-rendering functionality that we might need, acting as a sort of API backend to our app.

// routes/api/sign-in.ts

import { Handlers } from "$fresh/server.ts";
import { setCookie } from "std/http/cookie.ts";

export const handler: Handlers = {
  async POST(req) {
    const url = new URL(req.url);
    const form = await req.formData();

    const email = String(form.get("email"));
    const password = String(form.get("password"));

    if (email === "test@example.com" && password === "password1234") {
      const headers = new Headers();
      headers.set("location", "/");

      setCookie(headers, {
        name: "auth",
        value: "superzitrone",
        maxAge: 3600,
        sameSite: "Lax",
        domain: url.hostname,
        path: "/",
        secure: true,
      });

      return new Response(null, { status: 303, headers });
    } else {
      return new Response(null, { status: 403 });
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

We extract the email and username from the form data and, well, check for them being set to some specific values for now. If so, we return a Response with our special auth and a location header set. Setting the location header to "/" acts as a redirect to our home page (the status code must be in the 3xx range for this), where the route handler we just set up will then pick up the auth cookie.

Set up a sign-out route

Next, we need a sign-out route. We simply use the handy deleteCookie from Deno's std to delete the auth cookie and redirect to home:

import { Handlers } from "$fresh/server.ts";
import { deleteCookie } from "std/http/cookie.ts";

export const handler: Handlers = {
  GET(req) {
    const url = new URL(req.url);
    const headers = new Headers(req.headers);

    deleteCookie(headers, "auth", { path: "/", domain: url.hostname });

    headers.set("location", "/");

    return new Response(null, { status: 302, headers });
  },
};
Enter fullscreen mode Exit fullscreen mode

Great success!

Actual authentication with Supabase

Ok, now finally on to the fun part! We have a barebones flow in place, let's actually add Supabase to the mix. If you haven't already, go over to Supabase and create an account and a new project (it's super simple and free). In fact, I recommend you check out their blog some time, you can witness some really great technology in the making there. Once you've created a project, navigate to the SQL-Editor tab and you'll find a User Management Starter that you can use to get set up with everything we need.

Preparations

To start using Supabase in our app, let's set it up in a dedicated lib file supabase.ts:

mkdir lib && touch lib/supabase.ts
Enter fullscreen mode Exit fullscreen mode
// lib/supabase.ts

import { createClient } from "supabase"
import { config } from "dotenv/mod.ts";

config({ safe: true, export: true });

const SUPABASE_URL = Deno.env.get("SUPABASE_URL") || "";
const SUPABASE_KEY = Deno.env.get("SUPABASE_KEY") || "";

export const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
Enter fullscreen mode Exit fullscreen mode

We want to read .env files with the dotenv package, which also gives us the config function to make sure that all required environment variables are set. Have a look at the documentation for the details. We are going to add two .env files:

touch .env .env.example
Enter fullscreen mode Exit fullscreen mode

The .env.example file let's us define which environment variables our app needs to function and should be checked in to source control. The .env file on the other hand should not be checked in and should contain the actual keys from your personal Supabase project. Here's .env.example:

SUPABASE_URL=https://<projectName>.supabase.co
SUPABASE_KEY=<api_key>
Enter fullscreen mode Exit fullscreen mode

Add a sign-up route

Before we can actually authenticate any users with Supabase, we need to have a way to create them in the first place, d'uh! We'll keep it simple and create authentication based on e-mail address and password, none of that fancy sell-my-data social auth kinda shenanigans.

Let's be the responsible devs our CTO wants us to be and do this as DRY as possible. Since we basically need the same fields for sign-up and sign-in, we'll reuse the SignInForm we created and make it call a different url based on where it is being rendered. My OCD is also making me rename the thing to something more generic:

mv islands/SignInForm.tsx islands/AuthForm.tsx
Enter fullscreen mode Exit fullscreen mode

Make it look like so:

// islands/AuthForm.tsx

import { FormButton, Input, Link } from "components/index.ts";

type Props = {
  mode: "In" | "Up";
};

export default function AuthForm({ mode }: Props) {
  const signIn = {
    title: "Sign In",
    href: "/sign-in",
    text: "Have an account?",
  };

  const signUp = {
    title: "Create account",
    href: "/sign-up",
    text: "No account?",
  };

  const buttProps = mode == "In" ? signIn : signUp;
  const footProps = mode == "In" ? signUp : signIn;

  return (
    <div class="items-stretch min-w-0">
      <div class="flex justify-center">
        <h2 class="my-4">{buttProps.title}</h2>
      </div>

      <form method="post" class="flex flex-col space-y-4 min-w-0">
        <Input autofocus type="email" name="email" />
        <Input type="password" name="password" />
        <FormButton type="submit" formAction={"/api" + buttProps.href} class="!mt-8" >
          {buttProps.title}
        </FormButton>

        <p>{footProps.text} <Link href={footProps.href}>{footProps.title}</Link></p>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Ah, that's better. Time to also move the form from the home page to a dedicated sign-in and sign-up page:

touch routes/sign-in.tsx routes/sign-up.tsx
Enter fullscreen mode Exit fullscreen mode
// routes/sign-in.tsx

import { Handlers, PageProps } from "$fresh/server.ts";
import { getCookies } from "std/http/cookie.ts";

import { Layout } from "components/index.ts";
import AuthForm from "islands/AuthForm.tsx";

export type Data = {
  isAllowed: boolean;
};

export const handler: Handlers = {
  GET(req, ctx) {
    const cookies = getCookies(req.headers);
    return ctx.render({ isAllowed: cookies.auth == "superzitrone" });
  }
}

export default function Page({ data: { isAllowed }}: PageProps<Data>) {
  return (
    <Layout isAllowed={isAllowed}>
      <div class="flex justify-center">
        <div class="flex flex-col items-stretch w-[500px] md:w-2/3">
          <div class="flex justify-center">
            <img src="/logo.svg" class="w-16 h-16 mt-8 mb-4" alt="the fresh logo: a sliced lemon dripping with juice" />
          </div>

          <AuthForm mode="In" />
        </div>
      </div>
    </Layout>
  );
}
Enter fullscreen mode Exit fullscreen mode

I'm sure you can figure out how routes/sign-up.tsx looks like. Here's the updated home page, now with a link instead of the form:

// routes/index.tsx

import { Handlers, PageProps } from "$fresh/server.ts";
import { getCookies } from "std/http/cookie.ts";

import { Layout, Link } from "components/index.ts";

export type Data = {
  isAllowed: boolean;
};

export const handler: Handlers = {
  GET(req, ctx) {
    const cookies = getCookies(req.headers);
    return ctx.render({ isAllowed: cookies.auth == "superzitrone" });
  }
}

export default function Home({ data: { isAllowed }}: PageProps<Data>) {
  return (
    <Layout isAllowed={isAllowed}>
      <img src="/logo.svg" class="w-32 h-32" alt="the fresh logo: a sliced lemon dripping with juice" />

      <p class="my-6">You are currently {!isAllowed && "not"} signed in.</p>

      {!isAllowed ? <Link href="/sign-in">Sign In</Link> : <Link href="/api/sign-out">Sign Out</Link>}
    </Layout>
  );
}
Enter fullscreen mode Exit fullscreen mode

Don't forget to uncomment the sign-up link in components/Layout.tsx that I already sneakily put in there so we can actually reach our new route!

Alright. At this point, the authentication should still be working as before, but embedded in a moar better UI.

Implementation

Ok, now, finally. Some actual Supabase authentication. Let's do everything in order and start by implementing our sign-up flow. Add a new api endpoint to our app:

touch routes/api/sign-up.ts
Enter fullscreen mode Exit fullscreen mode
// routes/api/sign-up.ts

// ...

import { supabase } from "lib/supabase.ts";

export const handler: Handlers = {
  async POST(req) {
    const form = await req.formData();
    const email = form.get("email");
    const password = form.get("password");

    const { data: { user, session }, error } = await supabase.auth.signUp({
      email: String(email),
      password: String(password),
    });

    if (error != null) {
      // TODO: Add some actual error handling.
      console.error(error);
      return new Response(null, { status: 500 });
    }

    if (user && !session) {
      // TODO: A user has been created but not yet confirmed their e-mail address.
      // We could add a flag for the frontend to remind the user.
    }

    const exists = await supabase.auth.getUser(String(user));
    if (exists?.data.user) {
      // TODO: Handle user already existing.
    }

    const headers = new Headers();
    headers.set("location", "/");

    return new Response(null, { status: 303, headers });
  },
};
Enter fullscreen mode Exit fullscreen mode

As you can see there's a few things left to get this up to production standards but we're not going to bother with that right now.

Works on my machine™. On to the sign-in route to make sure it does on yours too! sign-in.ts becomes this:

// routes/api/sign-in.ts

// ...

import { supabase } from "lib/supabase.ts";

export const handler: Handlers = {
  async POST(req) {
    const url = new URL(req.url);
    const form = await req.formData();

    const email = String(form.get("email"));
    const password = String(form.get("password"));

    const { data: { user, session }, error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });

    if (error != null || user == null || session == null) {
      // TODO: Add some actual error handling. Differentiate between 500 & 403.
      return new Response(null, { status: 500 });
    }

    const headers = new Headers();
    headers.set("location", "/");

    setCookie(headers, {
      name: "auth",
      value: "superzitrone",
      maxAge: 3600,
      sameSite: "Lax",
      domain: url.hostname,
      path: "/",
      secure: true,
    });

    return new Response(null, { status: 303, headers });
  },
};
Enter fullscreen mode Exit fullscreen mode

At this point, it should be possible for you to create a new account, confirm the e-mail address and sign in to our little app. Cool beans!

Except, I tricked you a bit there. We are still setting the same old cookie with the magic string that our frontend is looking for. Sure, we only allow that to happen if the sign in with Supabase is successful but what we should really do here is setting the actual access token that Supabase returns into the cookie and check for that.

This, by the way, already hints at the real juicy part of this whole project - what are we going to do once we set an actual access token in the cookie? What are we going to compare that against? How would a page handler know what constitutes a valid session token? After all, there could be any string in there. Let's chew on that one for a bit, we'll tie it all together in the last chapter!

For now, let's move on to another intermediary step if you will and add a middleware that will take care of authentication for all our routes. This way, we won't have to duplicate that logic in all our page handlers but centralise it in a single place. Then, we can make it actually check for the real thing.

Add auth middleware and protected route

To protect a route, we'll use a middleware to do this properly, as explained above. A _middleware.ts file can be defined anywhere inside the routes folder which can be used to add custom logic that will be run before or after each request to that route. By adding it to the top-level routes folder, it will be run for any request to our app. Let's add the following for now:

touch routes/_middleware.ts
Enter fullscreen mode Exit fullscreen mode
// routes/_middleware.ts

import { MiddlewareHandlerContext } from "$fresh/server.ts";
import { getCookies } from "std/http/cookie.ts";

type User = {
  id: number,
  name: string,
  access_token: string,
}

export type ServerState = {
  user: User | null;
  error: { code: number, msg: string } | null;
};

export async function handler(
  req: Request,
  ctx: MiddlewareHandlerContext<ServerState>,
) {
  const url = new URL(req.url);
  const cookies = getCookies(req.headers);
  const access_token = cookies.auth;

  const protected_route = url.pathname == "/secret";

    const headers = new Headers();
    headers.set("location", "/");

  if (protected_route && !access_token)  {
    // Can't use 403 if we want to redirect to home page.
    return new Response(null, { headers, status: 303 });
  }

  if (access_token) {
    // Here, we will have an actual lookup of user data in the future.
    const user_data = { id: 42, name: "Spongebob", access_token };

    if (protected_route && !user_data)  {
      return new Response(null, { headers, status: 303 });
    }

    ctx.state.user = user_data;
  }

  return await ctx.next();
}
Enter fullscreen mode Exit fullscreen mode

For every request, we check if the user is trying to access our protected route. If so, we are trying to parse the auth cookie, and if we find it, we inject that user's data into the request which can then be picked up by our individual page handlers. Interesting concept those middlewares, huh? Do know how one would write a middleware that intercepted responses instead of requests? Hint: That ctx.next() can be used similar to how you write recursive calls, so to speak. Powerful stuff.

Moving on! As you can see, we created a new ServerState type here. With this, we can define a "standard" page props type (PageProps<ServerState>) for all our page handlers to consume, let's create a secret page and use it there first:

touch routes/secret.tsx
Enter fullscreen mode Exit fullscreen mode
// routes/secret.tsx

// ...

import { ServerState } from "routes/_middleware.ts";
import { Layout } from "components/index.ts";

export const handler: Handlers = {
  GET(_req, ctx) {
    return ctx.render(ctx.state);
  }
}

export default function Secret(props: PageProps<ServerState>) {

  const isAllowed = !!props.data.user;

  return (
    <Layout isAllowed={isAllowed}>
      <div class="flex flex-col items-center">
        <h2>Congrats, You've reached the secret page!</h2>
        <p>Here's a little treat:</p>
        <p class="text-[72px] text-align-center">🍋</p>
      </div>
    </Layout>
  )
}
Enter fullscreen mode Exit fullscreen mode

See what we're doing in the custom handler there? The ServerState we defined is now part of the ctx which we pass on to ctx.render() to inject it into the component. In there, we now have access to the server state and can use it to build out our business logic.

Try it out in the browser and you should now be able to sign in and out of the app and should only be able to reach the secret page if you're signed in, yeeha!

Clean up

Something's still off, though. We still have some custom handlers in place that are checking for the magic string cookie which became rather pointless since we have that actual protection mechanism at the route level now. We can basically standardise all our page handlers to simply pass on the server state into the page. And whilst we're on it, let's get even more DRY and move the isAllowed logic to Layout so we don't have to repeat it in every page we're creating.

Step by step. Here's the updated Layout component:

// components/Layout.tsx

// ...

import { ComponentChildren } from "preact";

import { ServerState } from "routes/_middleware.ts";
import { NavButton, NavLink } from "components/index.ts";

type Props = {
  children: ComponentChildren;
  state: ServerState;
};

export function Layout(props: Props) {
  const isAllowed = !!props.state.user;

  const buttProps = isAllowed
    ? { href: "/api/sign-out", text: "Sign Out" }
    : { href: "/sign-in", text: "Sign In" };

  return (/* omitted */);
}
Enter fullscreen mode Exit fullscreen mode

With this, we can now standardise all our pages, namely routes/index.tsx, routes/sign-in.tsx, and routes/sign-up.tsx, here's secret.tsx as an example:

// routes/secret.tsx

import { Handlers, PageProps } from "$fresh/server.ts";

import { ServerState } from "routes/_middleware.ts";
import { Layout } from "components/index.ts";

export const handler: Handlers = {
  GET(_req, ctx) {
    return ctx.render(ctx.state);
  }
}

export default function Secret(props: PageProps<ServerState>) {
  return (
    <Layout state={props.data}>
      <div class="flex flex-col items-center">
        <h2>Congrats, You've reached the secret page!</h2>
        <p>Here's a little treat:</p>
        <p class="text-[72px] text-align-center">🍋</p>
      </div>
    </Layout>
  )
}
Enter fullscreen mode Exit fullscreen mode

Repeat the above for all mentioned pages.

Complete the circle

One last thing to do. In routes/api/sign-in.ts, line 29-30, we can now actually set the access token we get from Supabase instead of our magic string:

setCookie(headers, {
  name: "auth",
  value: session.access_token,
  maxAge: session.expires_in,
  sameSite: "Lax",
  domain: url.hostname,
  path: "/",
  secure: true,
});
Enter fullscreen mode Exit fullscreen mode

Now, we have completely removed the pseudo-authentication that relied on our magic little string and upgraded our system to use an actual auth provider. Remember what I said about statefulness when we first implemented the sign-in with Supabase, though? Sure, we are now working with an actual access token and we are preventing unauthorised access in a robust centralised manner, but with all that in place, it still doesn't really matter what is in that cookie. You could set it to an arbitrary string and get access to our site. Try it out in your browser's developer tools to see for yourself. Change the value of the cookie to anything you want and navigate to the secret page. Heck, you could even create one from scratch, all you need to know is that the name is supposed to be "auth" - that's it. Fun times!

Persist sessions with Redis

Ok, this is obviously not going to withstand the scrutiny of our Chief Security Officer (you do still have one of those at your company, don't you?).

So what do we actually want to do exactly? As we said before, we need a way to compare the access token that a user passes to us in their cookie on each request to something that tells us that that token is actually legit. We know (or, assume) that what we are being handed from Supabase is legit but after we put it into the token, our app sort of loses knowledge of that.

What would be great is if there was a way to listen for any call to Supabase that hands out a token, cache that token and maybe some associated user data somewhere in a sort of session store and then, for every call to a protected route, have our middleware check in that session store if what it received is actually known to our app. This way, we would make our "backend" stateful in the way that it could remember who was signed in.

Enter, you guessed it, Redis, whose tagline literally is "an in-memory data structure store, used as a database, or cache". Boom, right on target.

We just have to figure out at which exact points in our authentication flow we want to loop Redis into the mix. If only we had a surefire way of catching all the relevant auth events and update our session store accordingly... I know, it's not even funny anymore: Obviously, the Supabase SDK provides exactly this, their recommended way of doing this sort of stuff. As if they knew what they were doing!

Ok, easy peasy. Let's add Redis to the mix. Just like with Supabase, let's create a lib that takes care of initialising an instance of it. For this to work, you need to have Redis installed on your system, obviously. Afterwards, go ahead and create the following:

touch lib/redis.ts
Enter fullscreen mode Exit fullscreen mode
// lib/redis.ts

import { connect } from "redis";

export const redis = await connect({
  hostname: "127.0.0.1",
  port: 6379,
});
Enter fullscreen mode Exit fullscreen mode

Of course this is no real way to do this, as we are hard-coding our app to only work in a local dev environment but this should suffice for now. Making all of this deployable is a whole different can of worms anyway that we don't need to concern ourselves with right now.

Next, let's use that super useful onAuthStateChange function from the Supabase SDK. I like to keep our concerns neatly separated so I will put this into our main.ts instead of our supabase lib as to not couple the initialisation of our libraries too tightly together.

// main.ts

// ...

import { supabase } from "lib/supabase.ts";
import { redis } from "lib/redis.ts";

supabase.auth.onAuthStateChange(async (event, session) => {
  if (event == "SIGNED_IN" || event == "TOKEN_REFRESHED" && session != null) {
    const { access_token, expires_in } = session!;
    const stringified = JSON.stringify(session);
    await redis.set(access_token, stringified, { ex: expires_in });
  }
});

await start(manifest, { plugins: [twindPlugin(twindConfig)], port: 8080 });
Enter fullscreen mode Exit fullscreen mode

We are listening for both the SIGNED_IN and TOKEN_REFRESHED events that are being emitted by Supabase. What's neat about this is that we don't have to "manually" concern ourselves with this in our sign-in API handler, and are free to change that around if we wanted to. Again, a nice separation of concerns.

Ok, on to our middleware. Remember that comment I made when we first created the middleware? That is exactly where we can now look up the actual access token that is being passed in, and, if we know it, inject some user data into the context (see line 31):

// routes/_middleware.ts

// ...

import { redis } from "lib/redis.ts";

export type ServerState = {
  user: User | null;
  error: { code: number; msg: string } | null;
};

export async function handler(
  req: Request,
  ctx: MiddlewareHandlerContext<ServerState>,
) {
  const url = new URL(req.url);
  const cookies = getCookies(req.headers);
  const access_token = cookies.auth;

  const protected_route = url.pathname == "/secret";

  const headers = new Headers();
  headers.set("location", "/");

  if (protected_route && !access_token) {
    // Can't use 403 if we want to redirect to home page.
    return new Response(null, { headers, status: 303 });
  }

  if (access_token) {
    const session = await redis.get(access_token);

    if (protected_route && !session) {
      return new Response(null, { headers, status: 303 });
    }

    const user = JSON.parse(session!.toString())?.user;
    ctx.state.user = user;
  }

  return await ctx.next();
}
Enter fullscreen mode Exit fullscreen mode

Woohoo, works for me!

Full disclosure, I also changed routes/index.tsx to look like this:

// routes/index.tsx

// ...

export default function Home(props: PageProps<ServerState>) {
  const isAllowed = !!props.data.user;
  return (
    <Layout state={props.data}>
      <img src="/logo.svg" class="w-32 h-32" alt="the fresh logo: a sliced lemon dripping with juice" />

      <h2>Supa Fresh Auth</h2>

      <p>
        An example app built with Deno's{" "}
        <Link href="https://fresh.deno.dev/" target="_blank">Fresh</Link>{" "}
        framework, using{" "}
        <Link href="https://supabase.com/" target="_blank">
          Supabase
        </Link>{" "}
        and <Link href="https://redis.io/" target="_blank">Redis</Link>{" "}
        to implement a simple cookie-based authentication scheme.
      </p>

      <div class="my-4">
        <a href="https://fresh.deno.dev" target="_blank" style={{ display: "block", width: "fit-content" }}>
          <img width="197" height="37" src="https://fresh.deno.dev/fresh-badge.svg" alt="Made with Fresh" />
        </a>
      </div>

      {!isAllowed ? <Link href="/sign-in">Sign In</Link> : <Link href="/api/sign-out">Sign Out</Link>}
    </Layout>
  );
}
Enter fullscreen mode Exit fullscreen mode

And since we are on the topic of disclosure, this design is heavily inspired by this other Fresh auth example. I won't pretend I have any real talent for app design, I can recognise it, however. Leave them a star!

All that's left is to clean up our session store on sign out:

// routes/api/sign-out.ts

// ...

import { redis } from "lib/redis.ts";

export const handler: Handlers = {
  async GET(req) {
    const url = new URL(req.url);
    const headers = new Headers(req.headers);

    const cookies = getCookies(req.headers);
    const access_token = cookies.auth;

    if (access_token) {
      await redis.del(access_token);
    }

    deleteCookie(headers, "auth", { path: "/", domain: url.hostname });

    headers.set("location", "/");

    return new Response(null, { status: 302, headers });
  },
};
Enter fullscreen mode Exit fullscreen mode

Now, this again leaves some things to be desired, for sure. For example, how would you go about doing this using onAuthStateChange and the SIGNED_OUT event? Right now, the supabase object we're using does not hold any state (it's supposed to be handed off to the browser and live there, one instance per user session). I'll leave thinking on this as an exercise to the reader.

But hey, we did it! The whole nine yards! We can sign users up, in and out all day long, using Supabase as our auth backend and persisting our sessions with Redis!

One thing you could do that wasn't possible before is open up the app in a new private window and sign up and in as two different users to the same dev server process running on localhost.

We are officially done!

Wrap up

In this article we went through the process of building a simple server-side rendered website with a basic, cookie-based authentication scheme using Fresh, Supabase and Redis. All in all I really am a big fan of the general approach that these modern meta-frameworks are taking. It really makes it easy to create a pit of success for a teams that want to get going quickly and have some best practices baked in without having to think about it. Combined with a slim, easy-to-use and batteries included backend like Supabase, these setups can really go a long way for real-world production use cases in my opinion.

Obviously, we only scratched the surface in this article, but it's a starting point. Here's two things that you could keep going with immediately:

  • A particularly low hanging fruit for UX would be a /welcome page that we redirect to after sign up and after e-mail confirmation. Also, we could pass the registration status into our ctx and nag the user about it in the frontend.
  • We could add a profile page where the user could keep some profile data. Supabase's metadata field is made for this.

Try it out and let me know how it went!

Some other important things we would need to add before considering taking this to production are things like a comprehensive test suite, proper error handling in our middleware, and lots of UX improvements in general but this was only a first look, after all.

I hope you enjoyed building our little app together and would love to hear your thoughts on it. What do you think about Fresh + Supabase or meta frameworks in general? Since this is the first time I wrote an article, did you like my style of writing? Would you be interested in another arcticle, a part two maybe?

Let's discuss in the comments section!

💖 💪 🙅 🚩
morlinbrot
Zisulin Morbrot

Posted on December 16, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related