It seems that there's no readily available middleware framework for callable functions, so inspired by this, I rolled my own. There are some general purpose chained middleware frameworks on NPM, but the middleware I need is so simple that it was easier to roll my own than to configure a library to work with callable functions.
Optional: Type declaration for Middleware if you're using TypeScript:
export type Middleware = (
  data: any,
  context: functions.https.CallableContext,
  next: (
    data: any,
    context: functions.https.CallableContext,
  ) => Promise<any>,
) => Promise<any>;
Here's the middleware framework:
export const withMiddlewares = (
  middlewares: Middleware[],
  handler: Handler,
) => (data: any, context: functions.https.CallableContext) => {
  const chainMiddlewares = ([
    firstMiddleware,
    ...restOfMiddlewares
  ]: Middleware[]) => {
    if (firstMiddleware)
      return (
        data: any,
        context: functions.https.CallableContext,
      ): Promise<any> => {
        try {
          return firstMiddleware(
            data,
            context,
            chainMiddlewares(restOfMiddlewares),
          );
        } catch (error) {
          return Promise.reject(error);
        }
      };
    return handler;
  };
  return chainMiddlewares(middlewares)(data, context);
};
To use it, you would attach withMiddlewares to any callable function. For example:
export const myCallableFunction = functions.https.onCall(
  withMiddlewares([assertAppCheck, assertAuthenticated], async (data, context) => {
    // Your callable function handler
  }),
);
There are 2 middlewares used in the above example. They are chained so assertAppCheck is called first, then assertAuthenticated, and only after they both pass does your hander get called.
The 2 middleware are:
assertAppCheck:
/**
 * Ensures request passes App Check
 */
const assertAppCheck: Middleware = (data, context, next) => {
  if (context.app === undefined)
    throw new HttpsError('failed-precondition', 'Failed App Check.');
  return next(data, context);
};
export default assertAppCheck;
assertAuthenticated:
/**
 * Ensures user is authenticated
 */
const assertAuthenticated: Middleware = (data, context, next) => {
  if (!context.auth?.uid)
    throw new HttpsError('unauthenticated', 'Unauthorized.');
  return next(data, context);
};
export default assertAuthenticated;
As a bonus, here's a validation middleware that uses Joi to ensure the data is validated before your handler gets called:
const validateData: (schema: Joi.ObjectSchema<any>) => Middleware = (
  schema: Joi.ObjectSchema<any>,
) => {
  return (data, context, next) => {
    const validation = schema.validate(data);
    if (validation.error)
      throw new HttpsError(
        'invalid-argument',
        validation.error.message,
      );
    return next(data, context);
  };
};
export default validateData;
Use the validation middleware like this:
export const myCallableFunction = functions.https.onCall(
  withMiddlewares(
    [
      assertAuthenticated,
      validateData(
        Joi.object({
          name: Joi.string().required(),
          email: Joi.string().email().required(),
        }),
      ),
    ],
    async (data, context) => {
      // Your handler
    },
  ),
);