Profile image
Ian Macartney
13 days ago

Authentication: Wrappers as “Middleware”

Layers. Photo by Hasan Almasi: @hasanalmasi on Unsplash

In this post, I’ll introduce a pattern that, like middleware, can add functionality before or after a request but is explicit and granular, unlike middleware. This is the first of a series of posts on pseudo-middleware patterns to help structure your Convex code. Let us know in Discord what else you want to see! If you want to see a typescript version of the code, you can reference it here.

The problem

Setting up auth in Convex is easy. However, the resulting code can end up cluttering your functions if you aren’t careful. Consider our auth demo’s function to send a message:

export default mutation(async ({ db, auth }, body) => {
  const identity = await auth.getUserIdentity();
  if (!identity) {
    throw new Error("Unauthenticated call to mutation");
  }
  // Note: If you don't want to define an index right away, you can use
  // db.query("users")
  //  .filter(q => q.eq(q.field("tokenIdentifier"), identity.tokenIdentifier))
  //  .unique();
	const user = await db
    .query("users")
    .withIndex("by_token", q =>
      q.eq("tokenIdentifier", identity.tokenIdentifier)
    )
    .unique();
  if (!user) {
    throw new Error("Unauthenticated call to mutation");
  }

  const message = { body, user: user._id };
  await db.insert("messages", message);
});

All of the endpoint logic is in the last few lines!

The goal

We want to provide a user where we’d normally access the auth object.

export default mutation(
  withUser(async ({ db, user }, body) => {
    const message = { body, user: user._id };
    await db.insert("messages", message);
  })
);

The withUser solution

Our wrapper function, provided below, may look a little complicated, so let’s talk about what it’s doing. Like mutation and query, withUser's only argument is a function. However, this function wants to be called with the user populated in the first parameter. So you can see in the call func({ ...ctx, user }, ...args), we are passing in the user that we looked up. Popping out a layer, withUser itself returns an async function that can be passed to query or mutation. So we define an inline async function that, given the normal ctx and some arguments (...args captures all of the arguments), will call the passed-in function func with the same arguments and the first parameter augmented. If this bends your brain, you’re not alone. Feel free to copy-paste. And for those nervous about how you’d type this in typescript, don’t worry. You can copy it from here.

/**
 * Wrapper for Convex query or mutation functions that provides a user.
 *
 * @param - func Your function that can now take in a `user` in the ctx.
 * @returns A function to be passed to `query` or `mutation`.
 */
export const withUser = (func) => {
  return async (ctx, ...args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      throw new Error(
        'Unauthenticated call to a function requiring authentication'
      );
    }
    // Note: If you don't want to define an index right away, you can use
    // db.query("users")
    //  .filter(q => q.eq(q.field("tokenIdentifier"), identity.tokenIdentifier))
    //  .unique();
    const user = await ctx.db
      .query('users')
      .withIndex('by_token', (q) =>
        q.eq('tokenIdentifier', identity.tokenIdentifier)
      )
      .unique();
    if (!user) throw new Error('User not found');
    return await func({ ...ctx, user }, ...args);
  };
};

Why extend the first argument with new parameters?

In python, the language I’ve worked with this pattern the most, the common practice for wrapper functions is to pass new parameters as the new first arguments to a wrapped function. However, for Convex we can leverage the fact that the first argument to functions is always ctx, and extending it comes with some great ergonomics. Using the fact that it’s an object, we can:

  • Flexibly add middleware-like parameters while maintaining the aesthetic of positional arguments matching the positional arguments on the client.
  • Add more wrappers in the future without having to keep track of which order the injected parameters are in, or knowing how many parameters they inject.
  • Use a middleware wrapper without using its provided value(s).

Codifying patterns

Those familiar with factories, decorators, or middleware will recognize this pattern. Rather than requiring every user to repeat the same lines of code at the start or end of a function, you can codify that pattern into a function. Beyond saving some time, leveraging patterns like this helps:

  • Keep your code DRY.
  • Increase the density of meaningful code.
  • Organize code by logical function. See aspect-oriented programming for an extreme perspective on this.
  • Give the code reviewer shortcuts.

I’ll expand on this last point. By noticing that it’s using the withUser helper, a reviewer can rest assured that this function will only be executed with a logged-in user. This becomes a more powerful reassurance when you compose these functions, such as withTeamAdmin, which we’ll see below.

Composing functions

The exciting part of using this sort of pattern is that it’s easy to compose it with other functions. In a simple example, we can combine mutation and withUser like so:

export const mutationWithUser = (func) => {
  return mutation(withUser(func));
};

This has the ergonomic benefit of decreasing the indentation level of your function, if you use prettier. However, you can imagine much more interesting compositions, like:

export const withTeamAdmin = (func) => {
  return withUser(withTeam(async (ctx, ...args) => {
    const {user, team} = ctx;
    const admin = await getTeamAdmin(user, team);
    if (!admin) throw new Error('User is not an admin for this team.')
    return await func({...ctx, admin}, ...args)
  }));
}

export const setTeamName = mutation(
  withTeamAdmin(async ({ db, team, admin }, name) => {
    console.log(`${admin.name} is changing team name`);
    await db.patch(team._id, {name});
  });
);

Why wrap at all?

You might be thinking to yourself that this is a lot of indirection for what could be a series of regular functions. Indeed, this pattern can introduce complexity for someone looking at the code for the first time. Where did the user arg come from? Why is my stack trace full of withX calls? Sometimes, it is clearer and less surprising to call regular functions. Here are some scenarios when wrapping is useful:

  • You want to control what the function returns.
  • You want to do work before and after the function, such as opening and closing a file.
  • You need to clean up if the called function throws an exception.
  • Misusing a function (such as forgetting to await it or handle its response correctly) would have serious implications.
  • You want to compose your behavior with the above scenarios consistently.
  • You will reuse these wrappers frequently.

Wrapping up

Using wrapper functions like withUser can help you organize your code into middleware-like blocks that you can compose to keep your function logic concise. A typescript version of the code is here, and used by Fast5 here. Let us know in Discord what you think and what you’d like to see next!