Supabase Auth with Deno Fresh
January 5, 2024
Supabase recently released an updated package for handling authentication outside of client-only environments. Most of the docs are geared around popular full-stack frameworks like NextJS or Remix, so I thought I’d try out setting up in Fresh. Turns out, it works pretty well!
Disclaimer - I’m fairly new to both Supabase and Fresh.
I’ve set up an example repo here.
Setup Supabase
You’ll need to set up a Supabase project to get started. Alternatively, you can set up the CLI for local development which will spin up a local Supabase project in a Docker container.
If you are using local development, you’ll need to update the supabase/config.toml
file to contain the correct URLs. Here’s an example with Fresh’s defaults:
[auth]
enabled = true
# ...
# HERE -> Update to match Fresh's localhost URL
site_url = "http://127.0.0.1:8000"
# ...
[auth.email]
# ...
# Here -> Set this to `true` to use the PKCE flow locally
enable_confirmations = true
You’ll also need to make sure your .env
file has the correct key values:
# .env
SUPABASE_URL=xxx
SUPABASE_ANON_KEY=xxx
Grab the NPM packages
Since Deno can run npm packages now, we can put those in our deno.json
file like so:
{
// ...
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.6.1/",
// ...
"@supabase/ssr": "npm:@supabase/ssr",
"@supabase/supabase-js": "npm:@supabase/supabase-js"
}
// ...
}
Fresh Plugin
I found it cleanest to set up a Fresh plugin called auth.ts
to contain every related to auth. It’s primarily middleware involved, so there is flexibility in how this is setup.
// plugins/auth.ts
import type { Plugin } from "$fresh/server.ts";
export const authPlugin: Plugin = {
name: "auth",
middlewares: [
// Coming up...
],
};
Add the plugin to your Fresh config:
import { defineConfig } from "$fresh/server.ts";
import tailwind from "$fresh/plugins/tailwind.ts";
import { authPlugin } from "./plugins/auth.ts";
// Set up some types we'll reference later
export type SignedInState = {
session: Session;
};
export type SignedOutState = {
session?: null;
};
export type AuthState = SignedInState | SignedOutState;
export default defineConfig({
plugins: [authPlugin, tailwind()],
});
Set up the client
The Supabase client will be a “server” client specifically for handling cookie-based authentication. It works with standard Request
and Response
objects. It took me a minute to wrap my head around how it works, but the gist is that you pass it Request
and Response
objects and the client will update the Response
you passed with the necessary cookies for authentication. You can then use that response to render the page.
// plugins/auth.ts
import type { FreshContext, Plugin } from "$fresh/server.ts";
import { createServerClient, parse, serialize } from "@supabase/ssr";
import { assert } from "$std/assert/assert.ts";
export function createSupabaseClient(req: Request, resp: Response) {
const cookies = parse(req.headers.get("Cookie") || "");
const SUPABASE_URL = Deno.env.get("SUPABASE_URL");
const ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY");
assert(SUPABASE_URL, "SUPABASE_URL is not set");
assert(ANON_KEY, "SUPABASE_ANON_KEY is not set");
return createServerClient(SUPABASE_URL, ANON_KEY, {
cookies: {
get(key) {
return cookies[key];
},
set(key, value, options) {
const cookie = serialize(key, value, options);
// If the cookie is updated, update the cookies for the response
resp.headers.append("Set-Cookie", cookie);
},
remove(key, options) {
const cookie = serialize(key, "", options);
// If the cookie is removed, update the cookies for the response
resp.headers.append("Set-Cookie", cookie);
},
},
});
}
Set up session refresh
There needs to be a middleware that runs on every request to ensure the session is in the correct state.
// plugins/auth.ts
import type { Plugin } from "$fresh/server.ts";
export const authPlugin: Plugin = {
name: "auth",
middlewares: [
// For every route, we ensure the session state is updated
{
path: "/",
middleware: {
handler: setSessionState,
},
},
],
};
The middleware handler is a little goofy because we have to set up an empty Response
object that the Supabase client will attach cookies to and then copy over those cookies to the real Response
. We do this because we don’t want to call await ctx.next()
before we’ve updated the session state.
// ...
async function setSessionState(req: Request, ctx: FreshContext) {
if (ctx.destination !== "route") return await ctx.next();
// Sanity check - start without a session
ctx.state.session = null;
// Create an empty response object here. We want to make sure we do this
// session refresh before going further down the middleware chain
const resp = new Response();
const supabase = createSupabaseClient(req, resp);
// Refresh session if expired
const { data } = await supabase.auth.getSession();
// Stash this on context for later...
ctx.state.session = data.session;
// Continue down the middleware chain
const nextResp = await ctx.next();
// Copy over any headers that were added by Supabase
// Note how we're spreading the headers before iterating. This ensures we're
// capturing potentially duplicated headers that Supabase might add, like
// chunked cookies.
for (const [key, value] of [...resp.headers]) {
nextResp.headers.set(key, value);
}
return nextResp;
}
Edit! Fixed an issue where duplicate Supabase headers weren’t handled properly. Thanks, @nikololay
Guard Protected Routes
Now that we have an accurate session state, we can start guarding any routes we want to be logged in. We can set up another middleware for a /dashboard
route:
// plugins/auth.ts
// A little helper for redirects. We will write this a lot...
import { redirect } from "../utils.ts";
// ...
export const authPlugin: Plugin = {
name: "auth",
middlewares: [
// ...
// For the dashboard route, we ensure the user is signed in
{
path: "/dashboard",
middleware: {
handler: ensureSignedIn,
},
},
],
};
function ensureSignedIn(_req: Request, ctx: FreshContext) {
if (!ctx.state.session) {
return redirect(
"/auth/signin?message=You must be signed in to access this page",
);
}
return ctx.next();
}
Dashboard Route
Let’s set up the /dashboard
route so we have a place to land authenticated users.
// routes/dashboard/index.tsx
import { FreshContext } from "$fresh/server.ts";
import { Container } from "../../components/Container.tsx";
import { SignedInState } from "../../plugins/auth.ts";
// Make this an `async` function so we can get the full context
export default async function DashboardPage(
_req: Request,
ctx: FreshContext<SignedInState>,
) {
const { session } = ctx.state;
const { user } = session;
return (
<Container>
<h1 class="text-xl">Dashboard</h1>
<p>Hello, {user.email}</p>
</Container>
);
}
Sign up
For signing up users we will use the PKCE flow described here. This will send a link to a user’s email that will send them back to our app and exchange a one-time code for a session.
Let’s first set up the callback route users will land on from their email:
import { Handlers } from "$fresh/server.ts";
import { redirect } from "../../utils.ts";
import { createSupabaseClient } from "../../plugins/auth.ts";
export const handler: Handlers = {
async GET(req) {
const requestUrl = new URL(req.url);
// Set up a successful response
const resp = redirect("/dashboard");
const code = requestUrl.searchParams.get("code");
const supabase = createSupabaseClient(req, resp);
if (code) {
await supabase.auth.exchangeCodeForSession(code);
}
return resp;
},
};
Now we can set up a sign up page:
// routes/auth/signup.tsx
import { Handlers } from "$fresh/server.ts";
import { assert } from "$std/assert/assert.ts";
import { createSupabaseClient } from "../../plugins/auth.ts";
export const handler: Handlers = {
async POST(req) {
// Set up the response we want to return if successful
const resp = new Response(null, {
headers: {
location: "/auth/signup?message=Check your email for the sign in link",
},
status: 303,
});
// Do whatever we need to grab the login details
const form = await req.formData();
const email = form.get("email")?.toString();
const password = form.get("password")?.toString();
assert(email, "email is required");
assert(password, "password is required");
// Setup the supabase client
const supabase = createSupabaseClient(req, resp);
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: new URL("/auth/callback", req.url).toString(),
},
});
if (error) {
return new Response(null, {
headers: { location: "/auth/signup?message=Could not sign up" },
});
}
// Return the response. Supabase client will have added cookies
return resp;
},
};
export default function Page(req: Request) {
const message = new URL(req.url).searchParams.get("message");
return (
<div>
<form method="post">
<label for="email">Email</label>
<input id="email" type="email" name="email" />
<label for="password">Password</label>
<input id="password" type="password" name="password" />
<button type="submit">Sign up</button>
</form>
{message && <p>{message}</p>}
</div>
);
}
If you’re using local Supabase development, you should see an email in your “Inbucket” that looks like this:
Sign In
The sign in page will look similar:
// routes/auth/signin.tsx
import { Handlers } from "$fresh/server.ts";
import { assert } from "$std/assert/assert.ts";
import { createSupabaseClient } from "../../plugins/auth.ts";
import { redirect } from "../../utils.ts";
export const handler: Handlers = {
async POST(req) {
const resp = redirect("/dashboard");
const supabase = createSupabaseClient(req, resp);
const form = await req.formData();
const email = form.get("email")?.toString();
const password = form.get("password")?.toString();
assert(email, "email is required");
assert(password, "password is required");
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return redirect("/auth/signin?message=Error signing up");
}
return resp;
},
};
export default function Page(req: Request) {
const message = new URL(req.url).searchParams.get("message");
return (
<div>
<h1>Sign in</h1>
<form method="post">
<label for="email">Email</label>
<input id="email" type="email" name="email" />
<label for="password">Password</label>
<input id="password" type="password" name="password" />
<button type="submit">Sign in</button>
</form>
{message && <p>{message}</p>}
</div>
);
}
Sign out
Sign out is even simpler:
import { Handlers } from "$fresh/server.ts";
import { createSupabaseClient } from "../../plugins/auth.ts";
import { redirect } from "../../utils.ts";
export const handler: Handlers = {
async GET(req) {
const resp = redirect("/auth/signin");
const supabase = createSupabaseClient(req, resp);
await supabase.auth.signOut();
return resp;
},
};
Summing Up
If everything worked out, you should be able sign up/out/in users and get to a dashboard page that looks like this:
Did I miss something? Do something wrong? Let me know!