Home Projects Blog About

Foreward

I’ve long preferred Vue over React, especially in the days of proper lifecycle hooks. I adopted Nuxt.js early on for all my projects, and still love it today. In recent years I have found myself using React more and more at work, and maybe I’m ill or deranged but I am actually… enjoying it?

The thing I don’t like? NextJS — the now accepted default way of ‘doing a React’. Sorry for the drive-by, I just don’t like the way Next works — but that’s a rant for another time. Enter Astro; A framework that allows you to use other frameworks, and you can even use more than one at a time. Prefer Vue but want to use some React thing like ReCharts? Just add both Vue and React to your project.

I kept an eye on it for a long time, but finally started using it in earnest last year. While it can be used to create a full stack app, I choose to seperate my frontend and backend because reasons.

What is Middleware?

Middleware allows you to intercept requests and responses and inject behaviours dynamically every time a page or endpoint is about to be rendered. — Astro Docs An important caveat, this may have unreliable results on non-SSR pages.

What does this mean for us? Well, we can do things like check for a user session, and rewrite the response if the user is not authenticated or redirect them to a login page.

Injecting Auth into Locals / Request Context

I run Astro on Cloudflare, so I am going to provide the example as I would use it. I will put more articles up in the future on this, but there are good docs here: https://docs.astro.build/en/guides/integrations-guide/cloudflare/. Additionally, I am going to use better-auth. If you’re using something else or you rolled your own auth solution like a true Chad, I am going to assume you can translate to your own use case.

// file: src/middleware.ts

import type { MiddlewareHandler } from 'astro';

// we're going to use the pure JS client as opposed to a particular framework client
import { createAuthClient } from 'better-auth/client';


export const onRequest: MiddlewareHandler = async (
  { locals, request }, next
) => {
  const authClient = createAuthClient({
    baseURL: locals.runtime.env.BETTER_AUTH_URL,
  })

  // we send the clients headers to the backend
  // cookies can be checked and return session data if valid
  // we have to pass the incoming requests headers through, 
  // otherwise our frontend may send it's own headers
  const { data: session } = await authClient.getSession({
    fetchOptions: {
      headers: request.headers, 
    }
  })

  // set the user and session data in locals
  locals.user = session?.user || null;
  locals.session = session?.session || null;

  // we're finished processing this middleware
  // move on to the next middleware OR render the page/route
  return next()
}

What we’ve just done is the following:

  • Instantiate an Auth Client that points to our backend.
  • Use that Auth Client to call the backend and check, using the visitors headers, if the visitor has a valid session.
  • If the visitor does have a valid session, the server responds with session and user data.
  • We then inject that data into Astro.locals so we can use it down the chain without invoking additional calls to our Auth service.

Setting Types on Locals (and Environment Variables)

Getting red squigglies under locals.user and locals.session? We can let intellisense know we’re not insane by adding the appropriate definitions — while we’re at it, lets define our environment variables to get some autocomplete + types. I use Cloudflare, which operates slightly differently to regular environments, but documentation can be found here: Astro Intellisense Documentation

// defining our env variables
type AppEnv = {
  BETTER_AUTH_URL: string;
}

// this is cloudflare specific
type Runtime = import('@astrojs/cloudflare').Runtime<AppEnv>;

type InferredUser = typeof import('./lib/auth').auth.$Infer.Session.user | null;
type InferredSession = typeof import('./lib/auth').auth.$Infer.Session.session | null;


declare global {
  namespace App {
    interface Locals extends Runtime {
      user: InferredUser
      session: InferredSession;
    }
  }
}

That’s a minimal reproduction, but demonstrates how we get those types into Astro.locals to get rid of those annoying red squiggles.

Guarding Routes

Now that we have our user data injected into the request context (or not) we can do stuff with it.

For example, if there is nothing in this site that anyone should ever be able to see without being authenticated, we can simply redirect all the routes by updating our src/middleware.ts file (don’t do this part, I’m just making a point).

const routeGuard: MiddlewareHandler = (
  { locals, request }, next
) => {
  const { user, session } = locals
  
  if (!user || !session) {
    return redirect('/login');
  }

  return next();
}

I’m hoping that from just a cursory glance you see the issue here.

Any route a user visits when unauthenticated will result in being redirected to /login. BUUUUUT, if they visit /login and they’re unauthenticated they’re going to be redirected to /login , but then because they’re not authenticated they get redirected to /login and so on until the browser decides enough is enough and throws an error.

How do we handle that?

Route Matching

My preferred way of doing this is with matching the ‘safe’ routes. These are routes that don’t require auth — which means the default case for an unauthenticated user is redirect to login or rewriting the response. I do this because I am a dummy sometimes and might forget to update my route guard exposing a route that SHOULD be protected. My backend API routes always involve user context so the page would be blank or throw errors, but lets just be extra safe because it costs $0 to not be an idiot.

This is what my full middleware.ts looks like (almost).

// file: src/middleware.ts
import type { MiddlewareHandler } from 'astro';
import { createAuthClient } from 'better-auth/client';

// we use this to run middlewares, sequentially 🤯
import { sequence } from 'astro:middleware'; 

// these are the routes a user can visit without being authenticated
const UNPROTECTED_ROUTES = [
  /^\/login($|\/.*)/, // matches /login and any query params that may be included
  /^\/signup($|\/.*)/,
  /^\/invite($|\/.*)/,
  // this is only going to match '/hello' so '/hello&who=matt' won't match
  /^\/hello/,
  // include your error pages
  /^\/500($|\/.*)/,
  /^\/400($|\/.*)/,
];

// function for checking whether route needs auth or not 'safe' = no auth required
const isSafeRoute = (path: string): boolean => {
  return UNPROTECTED_ROUTES.some((pattern) => pattern.test(path));
};

// we use this to inject user and session data to request context
// any middleware or route following this will have access to this data
const authMiddleware: MiddlewareHandler = async (
  { locals, request }, next
) => {
  const authClient = createAuthClient({
    baseURL: locals.runtime.env.BETTER_AUTH_URL,
  })

  const { data: session } = await authClient.getSession({
    fetchOptions: {
      headers: request.headers, 
    }
  })

  locals.user = session?.user || null;
  locals.session = session?.session || null;
  
  return next()
}

const routeGuard: MiddlewareHandler = async (
  { url, locals, redirect }, next,
) => {
  const pathName = new URL(url).pathname
  // check if route is safe
  if (isSafeRoute(pathName)) {
    // route doesn't need guarding, we can exit now
    return next();
  }

  const { user, session } = locals;

  if (!user || !session) {
     // no user or session in context, present login page, 
     // with where the user was intending to go
    return redirect(`/login&redirectTo=${url}`)
  }

  // guarded route, with user in context
  return next();
}

// order is important here, we want to do the Auth middleware first
export const onRequest = sequence(authMiddleware, routeGuard);

We’ve now got two pieces of middleware.

The first (authMiddleware) is for checking and injecting session and user data into the request context (Locals).

The second (routeGuard) is for checking whether a route requires Auth, checks if Auth is present in context, and redirecting IF the route 1) requires auth and 2) there is no user or session data in the request context.

Instead of just exporting our function as onRequest we give our functions names and wrap them in the provided sequence() function. This wrapper is going to make sure our middleware runs in the provided order: sequence(thisFirst, thisSecond) so by executing authMiddleware and then routeGuard, we know that if a user is authed, that will exist in the request context.

Thanks for reading, hopefully this was helpful to somebody. If you haven’t tried Astro yet, give it a go, it’s awesome.